Docs

The complete reference — every feature, every setting, every way to run openGym. The same documents live in the repository, next to the code they describe.

Android iPhone Self-hosting Ask an AI AI Coach Import your history Backups Build it yourself FAQ

Which openGym? The three flavors

One codebase, three ways to run it. Everything below applies to all of them unless a section says otherwise.

Mobile appA sideloadable Android APK (and a buildable iOS app). No account, no server, no sync — the phone is the account, data lives in the app's private storage, workout-day reminders are native notifications. Install-and-done.
Self-hostedTwo Docker containers on your own machine. Adds passkey profiles, sync across devices, installable as a home-screen PWA, optional admin dashboard, web push notifications. The flavor for households and training groups.
DemoThe in-browser demo — the real app seeded with example data, storing everything in your browser only. Useful to try before installing, and as a free plan editor (see Import).

You can start with the mobile app and move to a server later (or the other way round) — the backup file migrates everything, and the mobile app can also connect directly to your server.

Install on Android

  1. Open the download on your phone and tap Download for Android.
  2. Your browser will ask whether it may install apps — allow it. This is standard for any app installed outside the Play Store (called sideloading), and you can revoke the permission right after.
  3. Open the downloaded openGym.apk, confirm, done. No account, no setup.
openGym is deliberately not on the Play Store — no store account between you and an open-source app. The APK is signed; updates install over the old version and keep your data.

The same signed APK exists in three places, all the same file:

Got the file from anywhere else? Verify it against the registry's .sha256 before installing. Updates come to you: when a newer release exists, Settings → Data shows "openGym vX available" — one tap downloads the signed APK, checks its SHA-256 against the published checksum and opens the installer (no checksum, no install; the first time Android asks for the "install unknown apps" permission). Installing over the old version by hand works too — same signing key, data stays. Everything is stored on the phone itself. The app goes online for two things only: exercise images and animations from a CDN, on demand, and — when you open Settings — a look at the public release list on GitLab to see whether a newer version exists.

The app requests notification permission only when you switch the workout-day reminder on, and declares SCHEDULE_EXACT_ALARM so reminders fire to the minute. Reminders are scheduled per calendar date — a day you already trained, or rescheduled away, stays quiet.

Use on iPhone

Apple does not allow installing apps outside the App Store, so there is no iOS download — that's a platform rule, not a choice we made. What works instead:

The app, feature by feature

What's actually inside, grouped the way you'll meet it. All of it ships in every flavor.

Home & the weekly plan

Routines & the plan builder

Progression — weights that follow a rule

Running a workout

Statistics

Looks, languages & little things

Self-host with Docker

The self-hosted flavor is two small containers (web + API) and a folder of your data. You need Docker with Compose:

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

Open http://localhost:8080, tap Create profile, and you're in. The first launch downloads the exercise media (~140 MB) once into media/img and media/gif. Check it's healthy:

docker compose ps
curl http://localhost:8080/api/health      # {"ok":true,...}

Logs: docker compose logs -f · stop: docker compose down · build from source instead of pulling: skip the pull and run docker compose up -d --build — no Node needed locally either way.

The passkey rule (read this before opening a port)

openGym signs you in with passkeys (WebAuthn — Face ID, fingerprint, or your password manager). Browsers enforce two rules: a passkey is bound to an exact hostname, and it only works over HTTPS — with one exception, http://localhost.

So http://localhost:8080 works on the Docker machine, but your phone cannot use http://192.168.x.x:8080 — that's neither localhost nor HTTPS, and the passkey prompt won't appear. To use openGym from your phone you need a real HTTPS hostname (below). Until then, LAN devices can still use guest mode (data stays in that browser) or the mobile app's pairing mode.

HTTPS on your own domain

Put anything that terminates TLS in front of the web container. Pick whichever you already run:

Then set the domain in .env and apply it:

# .env
RP_ID=gym.example.com
ORIGIN=https://gym.example.com

docker compose up -d   # restart alone does NOT re-read .env

Visit your domain, create your profile, add it to the home screen (iOS: Share → Add to Home Screen · Android: ⋮ → Add to Home screen).

Changing RP_ID later invalidates existing passkeys — they were bound to the old hostname, and everyone registers again. Settle the domain before people join.

Want a valid certificate without exposing anything to the internet — HTTPS on a LAN-only address? That works too (wildcard cert via a DNS challenge, Caddy in front): docs/SELF_HOSTING_HTTPS.md. A VPN or an auth proxy (Authelia, Cloudflare Access…) in front composes with all of this.

Notifications

A self-hosted instance can push two kinds of alert to your phone or desktop, even with the app closed: rest timer over, and the workout-day reminder. Turn them on per profile in Settings → Notifications (needs a signed-in profile and HTTPS). Nothing to configure server-side: VAPID keys are generated on first run and saved to ./data/vapid.json, and each browser reports its own timezone. Set VAPID_SUBJECT=mailto:you@example.com if you'd rather push services could reach an inbox than your origin URL.

Where it works: any desktop browser, Android Chrome, and on iOS only the app added to the Home Screen (a Safari tab has no Web Push). The Android APK doesn't use Web Push — its reminder is a local notification scheduled on the phone. A reminder that was due while the server was down or restarting is still sent up to 15 minutes late, once; the browser re-registers its subscription with the server on every signed-in start, so one the server lost heals itself; and a rest-timer alert belongs to the device that started the rest, so finishing a rest on the desktop no longer silences the phone.

Sync between devices

Signed in, the profile on the server is the truth. Signing in on a device adopts it — settings, plan, history — and if that device logged workouts while signed out, one dialog asks whether to add them to the profile or keep the profile as it is. While the app is open it asks the server every 30 seconds (and every time it comes back to the foreground) whether anything changed and fetches only what did, so a session logged on the phone shows up on the desktop within half a minute. Two devices that both changed something are merged on the server's revision — every workout, routine and weigh-in from both sides is kept, settings follow the newer copy — instead of one overwriting the other. Without a network the app keeps working on the device and says so under the header; the changes go up when the connection is back.

Fitting it into an existing stack

The defaults assume openGym is alone: a service called api on port 3000, nginx on 80 inside the web container. Merging into a compose file that already has an api, or fronting it with your own proxy on another port, is four .env settings — WEB_PORT, NGINX_PORT, BACKEND, PORT. The web image renders its nginx config from these at container start, so they work on a prebuilt image, no rebuild.

Updating

git pull                  # picks up compose/config changes
docker compose pull       # (or: docker compose up -d --build, if building from source)
docker compose up -d

Your ./data and the downloaded media are untouched; the app shell is versioned so clients pick up changes on next load. Releases are announced on GitHub and Discord; container images (amd64 + arm64) are published to GitLab's registry and mirrored to GHCR on every release.

Every setting

All configuration is one .env file (see .env.example); the defaults are what you get if you touch nothing.

RP_IDHostname passkeys are bound to. A bare hostname: no scheme, no port, no slash. Default localhost.
ORIGINFull URL the app is served from — with scheme, without trailing slash. Must match the address bar exactly. Default http://localhost:8080.
RP_NAMEName shown in the passkey prompt. Default openGym.
WEB_PORTHost port for the web UI. Default 8080.
NGINX_PORTPort the web container listens on, inside the container. Default 80.
BACKENDName of the API service /api is proxied to — change it if yours isn't called api.
PORTPort the API listens on; the web container proxies to the same value. Default 3000.
SESSION_DAYSHow long a sign-in lasts, in days. Default 90.
ADMIN_UIDSComma-separated user ids that get the admin dashboard. Default: none — a fresh instance has no admin.
INVITE_ONLYSet 1 and new profiles need an invite code you generate from the dashboard. Existing accounts keep working. Default: off.
ALLOW_GUESTSet 0 to remove Continue without account. Guest data isn't deleted — it stays in that browser and returns if you re-enable guests, or moves into a profile created on the same device. Default: on.
AUDIT_LOGRecord sign-ins and admin actions to the activity log — set 0 to record nothing. Default: on.
AUDIT_MAXEvents kept in the activity log; 0 for no limit. Default 5000.
AUDIT_DAYSDays kept in the activity log; 0 to keep until AUDIT_MAX. Default 90.
AUDIT_IPRecord the caller's address: off, net (network only, e.g. 203.0.113.0/24) or full. Default off.
PASSWORD_LOGINSet 1 to offer name-and-password sign-in next to passkeys. Default: off.
TRUST_PROXYLet the sign-in throttle read the visitor's address from proxy headers. 1 in docker-compose.yml, where the web container is the only way to the API; leave it off if you run the API without it.
DEFAULT_LANGLanguage of the sign-in screen and of new profiles, e.g. de or pt-BR. Default: the visitor's browser language.
BASE_PATHServe openGym under a subpath such as /gym, no trailing slash. Default: the site root.
MEDIA_UPLOADSSet 0 to turn off photo and video uploads (custom-exercise pictures, workout photos); the link field stays. Default: on.
MEDIA_QUOTA_MBUpload space per profile in MB; 0 for no cap. Default 200. Lower it on an instance with open signup.
MEDIA_MIN_FREE_MBUploads are refused while the disk under ./data has less free space than this. Default 512. Size limits per file type are in SELF_HOSTING.md.
API_TARGETWhich API image to build: default, or coach to add the Claude Agent SDK and Codex CLI runtimes. API-key providers work on default.
VAPID_SUBJECTContact URL sent with push notifications. Default: your ORIGIN.
COACH_DISABLEDSet 1 to switch the AI Coach off harder than any toggle. COACH_JOB_TIMEOUT_MS raises the job budget for slow local models.

Two things that look like settings but aren't: DATA_DIR is pinned to /data by docker-compose.yml and mapped to ./data — change the host side of that volume, not the variable. And VITE_IMG_BASE/VITE_GIF_BASE are build-time values baked into the frontend bundle — setting them next to docker compose does nothing to a prebuilt image (see Troubleshooting).

Multiple users, admin & the activity log

Anyone who can reach the URL can create their own profile — each gets isolated data. That's the default: open signup, no admin. To control who gets in:

ADMIN_UIDS=youruserid      # your id is in ./data/db.json under users[].id
INVITE_ONLY=1              # new profiles need an invite code
ALLOW_GUEST=0              # remove "Continue without account"

Register your own passkey profile first, then put your id in ADMIN_UIDS. You'll get an Admin dashboard link in Settings: who's training right now, each user's workout history and body weight, disabling an account (signed out and locked out everywhere until re-enabled), and — with invite-only on — generating and revoking invite codes. Admin access rides on your passkey and is enforced server-side; there is no separate admin login.

Signing in: passkeys, more devices, passwords

A profile can hold up to 20 passkeys — one per phone, laptop or password manager — listed in Settings → Account. A new device without a synced passkey joins with a one-time code or QR from a device that is already signed in. Sign out everywhere ends every session and unpairs phones at once.

For people who can't use passkeys (a plain http:// LAN address, a browser that only offers a hardware key) an instance can switch on name-and-password sign-in with PASSWORD_LOGIN=1. It is off by default; nobody has a password until they set one in Settings, the sign-in routes are throttled, and an admin can hand out a one-time reset code. The details are in SELF_HOSTING.md.

INVITE_ONLY and ALLOW_GUEST answer different questions and are usually set together: invite-only controls who may create a profile; the guest button never creates one — guest mode keeps everything in that browser and never talks to the server at all.

The activity log

The dashboard keeps an activity log: sign-ins, sign-outs, failed attempts, refused signups, and every admin action. It lives in ./data/audit.log as one JSON object per line — tail -f and jq work on it directly, and the dashboard reads the same file. On by default, keeping the last 5,000 events or 90 days, whichever comes first.

What it deliberately does not record: IP addresses (unless you opt in via AUDIT_IP), browser user-agents (a fingerprint), and the passkey id of a failed sign-in (it would let you follow an unknown device between attempts). Guests never appear — nothing they do touches the server. Clearing the log records the clear itself, and event ids keep counting, so a gap is always visible.

Connecting the mobile app to your server

The mobile app normally keeps everything on the phone. It can instead connect to your self-hosted instance — your data then lives there, synced like the browser PWA. Same app, not a different download; offered on first launch and under Settings → Connect to my server.

Passkeys can't work inside the app's WebView (it never runs at your real hostname), so the device is paired instead:

  1. In a browser that's already signed in: Settings → Pair the mobile app — shows a one-time code, valid 5 minutes.
  2. In the app: enter your server's address and that code. Done.

Worth knowing: the pairing exchanges the code for a bearer token that is the same signed value a session cookie carries — so "sign out everywhere" in the browser revokes paired apps too. Use an HTTPS address if at all possible (the token would otherwise cross the network in plain text). A connected app keeps working without a network on the last copy it synced — it shows an "Offline" line under the header and pushes your changes as soon as the connection is back, same as the browser PWA. Settings → Disconnect syncs one last time, then drops the device cleanly back to local mode. (For the curious: it's POST /api/pair/create + /api/pair/redeem in api/server.js.)

Let an AI coach write your plan

Separate from the read-only bridge above, openGym has an optional AI Coach. Answer a few questions — goal, days a week, session length, equipment, anything to work around — and it designs a week of routines. Later it reviews what you actually logged and proposes changes; you can also ask it to look at one workout (a debrief: a score, what went well, what to watch, what to do next time) or to improve a single routine and leave the rest alone. If the admin allows it and at least three people opt in, it can show anonymous medians so you can see how you sit against the others on your instance.

How it works

The Coach is a chat. The first visit is a short intake, one question per screen; after that, every request works the same way:

  1. The server builds a JSON payload from a by-name allowlist: your plan, the sets you logged in the review window, weigh-ins, your intake answers, and a few preferences — under a pseudonym. Your name, e-mail, passkeys and everyone else's data are not in it, by construction: fields are copied in one by one, nothing is passed through.
  2. That payload goes to the provider you configured — there is no openGym service in the middle.
  3. The answer is parsed and validated against a closed list of change types (add, remove or swap an exercise, sets, reps, schedule, progression policy…). An answer that names an exercise that doesn't exist, invents a change type, or breaks a rule is refused — it gets exactly one repair round, then the job fails rather than guessing.
  4. What survives is shown to you as a card, one checkbox per change, each with the evidence behind it. Nothing touches your plan until you accept it, every proposal stays in the chat history whether you said yes or no, and the last accepted change-set can be undone in one tap.

Providers

It is off by default. An admin turns it on in Settings → Admin dashboard, on the AI Coach card, and picks who answers:

Anthropic, OpenAI, Google GeminiPaste an API key from the provider's console. Plain HTTPS from the api process — runs on the default image.
OpenAI-compatible endpointAny server that speaks the Chat Completions API: Ollama, LM Studio, vLLM, OpenRouter, a gateway of your own. A key only if the endpoint wants one. Also the default image.
Claude Agent SDK / Codex CLIRun a real AI runtime inside the container — the one case that needs the bigger build: API_TARGET=coach docker compose up -d --build api

Run it fully local — no cloud, no cost

A model on your own hardware is just the compatible endpoint with no key. The usual way is an Ollama container next to openGym:

# docker-compose.yml — add next to the openGym services (same file = same network)
  ollama:
    image: ollama/ollama
    restart: unless-stopped
    volumes:
      - ./data/ollama:/root/.ollama
    environment:
      - OLLAMA_KEEP_ALIVE=-1     # keep the model (and its prompt cache) loaded
      - OLLAMA_NUM_PARALLEL=1
    mem_limit: 4g
docker exec -it <ollama container> ollama pull qwen2.5:3b

Then on the AI Coach card (Settings → Admin dashboard): provider OpenAI-compatible endpoint → Base URL http://ollama:11434 → List models → pick the model → Test the Coach. That's the whole setup.

Any model you can pull, you can use. ollama pull whatever you like and it appears under List models — the admin is free to run anything the box can hold, and each provider remembers its own model choice, so switching around never loses one. Honest sizing, from measuring exactly this on a small box:

A GPU machine elsewhere on your LAN works the same way: run Ollama or LM Studio there and point the Base URL at it, e.g. http://192.168.1.50:11434.

On the phone

Settings → AI Coach offers two ways in: pair the phone with your self-hosted instance and the Coach runs there under the rules above, or bring your own API key and the phone calls the provider directly — same allowlist, same validator, key in the platform's secure storage, ten runs a day.

Privacy and limits

Before anyone uses it, a consent screen lists exactly which categories of data leave the server — generated from the same module that builds the payloads, so it cannot drift from what actually happens. The admin can cap runs per person and per instance per day, and COACH_DISABLED=1 in the environment switches the feature off harder than any toggle. The Coach is not a doctor or a physiotherapist — if something hurts, ask a professional. Full details, including exactly what is sent and how credentials are stored: docs/AI_COACH.md.

Ask an AI about your training (MCP)

openGym ships an optional MCP server: a small read-only bridge that lets a client like Claude Desktop, Cursor or Cline answer questions from your actual log — "what did I bench last week?", "which muscle have I not trained this month?". It runs on your machine, is spawned by the client over stdio, reads the same ./data files the API writes, and its numbers come from the same pure functions the Stats screen uses — so the answers match the app exactly.

Eight tools in v1:

list_routinesWhat routines are saved? (names + exercise counts)
get_routineWhat does one routine prescribe, set by set?
get_week_planThis week's plan, including any date override.
list_workoutsRecent sessions — dates, sets done/planned, volume, duration, PRs.
get_workoutFull set-by-set breakdown of one session, by id or date.
get_bodyweightWeigh-ins, the goal line, deltas vs goal.
estimate_1rmBest 1RM for an exercise + trend, or a PR table across all.
muscle_balanceMuscles trained this week/month/all-time, ranked — and the neglected ones.

It is not part of the Docker build — if you don't use an AI assistant, it simply isn't there. No new auth (the filesystem is the boundary), no telemetry, no network; the LLM never sees passkeys or session secrets. Setup is cd mcp && npm install plus a few lines of client config: mcp/README.md.

Import your training history

Coming from another tracker? Settings → Data → Import from another app reads a CSV export; these apps work without touching the file:

FitNotes (Android)Settings → Backup/Export → Spreadsheet Export
FitNotes 2 (iOS)Export workouts as CSV
StrongSettings → Export Data
HevySettings → Export & Import Data (workouts or measurements) — or skip the CSV entirely, below
GravlProfile → Export Data
Apple HealthBody-weight history straight out of the XML export

Exercise names are matched against the library; anything genuinely ambiguous becomes one of your own custom exercises instead of being guessed at — nothing in the file is dropped. Session lengths and the RPE they record carry over. A summary shows exactly what will happen before anything is written, and importing is idempotent — running it twice never duplicates a workout.

Hevy, directly over the API

With Hevy Pro, skip the CSV: create an API key under Hevy → Settings → Developer, then in openGym use Settings → Import from Hevy, paste the key (used only for that import — never saved), and choose any mix of workouts, routines and weigh-ins. Workout days that already have data here are left alone; imported routines are always added as new plans.

Rolling your own CSV

Anything with date, exercise-name and weight/reps columns imports. Minimal example:

workout name,exercise,date,weight kg,reps
Leg Day,Squat,2026-08-21,120,5
Leg Day,Squat,2026-08-21,125,4
Leg Day,Leg Press,2026-08-21,200,1

Recognised headers (first match wins) for each field: exercise (also exercise name/title), date (workout date), start/end time, workout name (title, workout), category (body part, muscle group), weight (weight kg, weight lbs, plus a weight unit column), reps (repetitions), rpe, rir (reps in reserve), distance (distance km + unit), seconds (duration seconds, set duration sec), time (duration), set type, note (comment(s), notes, workout notes). The full table lives in docs/DATA_IMPORTS.md.

Importing plans

Routines travel as their own small JSON file (Plan → Share your plan → Export plan file exports every current routine; importing merges). Don't want to share everything? Edit the JSON, or use the demo as a free plan editor: import there, prune, export a fresh file. The demo stores everything in your browser — nothing is public or shared.

Backups & your data

Per user

Settings → Data → Export backup (JSON) writes your entire log — plan, workouts, weights, settings — into one file. On the mobile app it goes out through the share sheet (Files, AirDrop, mail…); in the browser it downloads. Import backup restores it anywhere: another phone, your self-hosted instance, or a fresh install. That file is the whole point of openGym — your training log as a file you own.

Per instance (self-hosted)

Everything lives in ./data on your host:

db.jsonProfiles and public passkeys. Private keys never touch the server — they stay in your phone's secure hardware or password manager.
state-<user>.jsonOne file per user: plan, workouts, body weight, settings.
audit.logThe activity log (if on) — one JSON object per line.
secretThe session-cookie signing key.
vapid.jsonPush-notification keys, generated on first run.
tar czf opengym-backup-$(date +%F).tar.gz data/

That archive is everything — profiles, passkeys, history. It also contains audit.log with everyone's sign-in times; worth knowing before shipping it to a backup service you don't run. Restore by unpacking into the project folder.

How it's built

Two containers, one folder, no database server:

The training logic — progression rules, 1RM estimation, how a logged session is read back — lives in pure functions under frontend/src/lib/ with tests next to them; the MCP server and the mobile app import the very same functions. Every pull request runs the full suites (frontend, api, mcp) on GitHub Actions and builds and boots the api images. After the merge, CI on the GitLab mirror builds the amd64+arm64 images, signs the APK and publishes the release — tagging vX.Y.Z is what ships everything.

openGym is developed with Claude Code, Anthropic's coding agent: a large share of the code, tests and documentation is drafted in Claude Code sessions. What goes in, every review and every release is the maintainer's call, and each release is tested on a staging instance and on real phones first. The app itself needs no AI service — the AI coach and the MCP server are opt-in. More on this in the README.

The exercise database comes from hasaneyldrm/exercises-dataset: 1,324 exercises with animations and instructions. The metadata and instruction text are MIT; the images and animations are third-party media that openGym never redistributes — your instance downloads them from upstream on first run (see the FAQ for the licensing details).

Build it yourself

Everything on this page is built from the public repo:

Troubleshooting

Passkeys fail even though the config looks right

The most common support question — work through these in order:

  1. Ask the server what it actually loaded: docker compose logs api | grep 'gym-api on' prints the rpID/origin it runs with. If that disagrees with your .env: docker compose restart does not re-read .env — use docker compose up -d.
  2. Check the exact shape: RP_ID is a bare hostname (gym.example.com — no https://, no port, no slash); ORIGIN is the full origin with scheme and without trailing slash. Both must match the address bar exactly.
  3. Behind a tunnel/proxy, use the public hostname — never the container name, LAN IP or internal port. The tunnel's own route may point wherever it likes.
  4. www. is a different host. A passkey registered on gym.example.com won't work on www.gym.example.com — pick one, redirect the other.
  5. Changing the hostname invalidates existing passkeys. Everyone registers again.

Everything else

No passkey prompt on my phoneYou're on http:// or a bare IP. Passkeys need HTTPS or localhost — set up a domain, or use guest mode / the mobile app.
"verification failed" on loginRP_ID/ORIGIN don't match the address bar — see the checklist above, starting with what the server logged.
Media didn't downloaddocker compose logs media, re-run docker compose up -d, or run ./scripts/fetch-media.sh.
Port 8080 already usedWEB_PORT=9090 in .env (and update ORIGIN for local testing).
No "Notifications" option in SettingsNeeds a signed-in profile and HTTPS (or localhost) — guest mode and plain HTTP over LAN can't subscribe.
Day reminder fires at the wrong timeToggle it off and on so it re-detects your browser's timezone (also happens on every app load).
"Keep screen awake" shows as unsupportedThe Wake Lock API needs HTTPS or localhost; iOS also refuses it in Low Power Mode.
Want to reset a stuck loginDelete the cookie — sessions are just signed cookies. "Sign out everywhere" in Settings ends every session (and paired apps) at once.
docker compose pull says "denied"Build from source instead: docker compose up -d --build.
Exercise images blank in an old buildFixed in current images. VITE_IMG_BASE/VITE_GIF_BASE are build-time: they're baked into the bundle when the frontend compiles, so setting them at runtime does nothing to a prebuilt image — redirect media in your reverse proxy instead, or rebuild.

Still stuck? The Discord is the quickest way to get an answer; Discussions are better for anything the next person should find by searching, and issues for bugs.

Privacy — the complete list

FAQ

Is it really free?

Yes — free as in cost and as in freedom (AGPL-3.0). No pro tier, no locked features, no ads, and the license keeps every fork open source too. If it replaced a paid tracker for you, there's a coffee button — a star, a bug report or a pull request is worth just as much.

Why isn't it on the Play Store / App Store?

By choice (and on iOS, by Apple's rules for sideloading). Store accounts cost money, impose terms that sit badly with the AGPL, and put a gatekeeper between you and an app whose whole point is that your data is yours.

Mobile app or self-hosted — which one should I pick?

If you just want to track your training: the mobile app. If you want your data on your own hardware, synced across devices, with profiles for the people you train with: self-host. You can start with the app and move to a server later — the backup file migrates everything, or pair the app to your server and keep using it.

What's on the roadmap?

A small, themed release roughly every two weeks. Next up: a session queue beside the fixed week, programmes and phases, a second round on the progression engine (AMRAP, %1RM and 5/3/1-style waves), cardio intervals; then the move to a database (v1.4.0, the one compatibility break), better search, OIDC login, a trainer role and an App Store build for iPhone. The full plan with dates is in ROADMAP.md; ideas and pull requests welcome.

Was openGym written with AI?

It's developed with Claude Code, and a large share of the code is drafted that way — reviewed, tested and released by the maintainer. The app itself calls no AI service unless you switch the coach on. See How it's built.

Where does the exercise database come from?

From hasaneyldrm/exercises-dataset: 1,324 exercises with animations and instructions, localized in 12 of openGym's 17 UI languages — including openGym's separately curated Brazilian Portuguese translation. That dataset licenses the two halves differently, and so does openGym: the exercise metadata and instruction text are MIT, while the images and animations are third-party media used under the dataset's terms rather than openGym's AGPL — their ownership is currently disputed between Gym visual and ExerciseDB, and a clarification has been requested.

openGym does not redistribute that media — your instance downloads it from the upstream source on first run. If you want to reuse it yourself, commercially or otherwise, clear it with the rights holder first. Full third-party notices, including the body-diagram geometry, are in NOTICE.md.

What does the AGPL mean for me?

You can self-host, use, modify and share openGym freely. If you run a modified version as a network service, you must offer that version's source under the same license — that's the clause that keeps openGym from ever becoming someone's closed, proprietary product. Just using or self-hosting it unmodified obliges you to nothing.