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.
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 app | A 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-hosted | Two 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. |
| Demo | The 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
- Open the download on your phone and tap Download for Android.
- 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.
- Open the downloaded
openGym.apk, confirm, done. No account, no setup.
The same signed APK exists in three places, all the same file:
- This site's download button.
- GitLab's package registry —
every version under
opengym-android/<version>/, with a.sha256checksum beside it. - The GitHub release for that version, with the APK and its
.sha256attached.
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:
- Self-host + home screen (recommended): open your own openGym instance in Safari → Share → Add to Home Screen. You get a full-screen app with its own icon, passkey sign-in and sync — no expiry, no fees.
- Build it onto your own iPhone: with a Mac and Xcode 15+, the native Capacitor app runs on your device with a free Apple ID (Apple expires the signature after 7 days; re-run from Xcode to renew — AltStore can automate that re-signing over Wi-Fi). See docs/MOBILE.md.
- Just looking? Try the in-browser demo — the real app with example data, no account and nothing to install.
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
- A routine per weekday. The home screen knows what day it is, shows this week with what's done and what's planned, and starts today's session with one tap.
- Reschedule any day. Sick, missed a session, fewer gym days this week? Move a workout to another date without touching the weekly plan itself — the override applies to that date only.
- Weeks start where you say. Monday or Sunday, in Settings → General. The weekly plan, the day strip on Home, the calendar grid and every “this week” total — the workout count, the week streak, the seven-day muscle balance — all follow it. Existing profiles keep the Monday week they have been looking at.
- On a rest day it says what’s next. Rather than a full stop, the Home row names the next day that actually has something to train, and which routine it is. Rescheduled days count; a routine with no exercises in it does not.
- Body weight on the home screen: your latest weigh-in, the trend, and how far you are from the goal you set.
- Streaks: consecutive training weeks, workouts this week, and totals.
Routines & the plan builder
- 1,324 exercises — searchable, with animated demonstrations and step-by-step instructions, plus a filter for the equipment you actually own (the filter options adapt to what you've picked, so every combination on screen has results).
- Your own exercises: a name and a body part is enough; they behave like built-in ones everywhere, with an optional description instead of an animation.
- Your own picture on your own exercises: give an exercise you made a photo, a GIF or a short video, plus a link to a video or guide. The file is shrunk and stripped of its location data on the device before it is uploaded; signed in, it syncs with your profile and still shows offline.
- Replace an exercise in the routine editor and keep its sets, reps and weight.
- Supersets: plan them into a routine, or pair two exercises mid-session with "make superset with previous/next" — then work through the group back-to-back with a single rest at the end of each round. Unpair any time; a group of one dissolves itself.
- Warm-up sets: mark the ramp-up rows as warm-ups and they stay out of every number that shouldn't see them — no effect on estimated 1RM, progression, or the fatigue map, while still being right there in the session. A weight change cascades down the rows that share its phase, not across the divide.
- Timed exercises: planks, hangs, wall sits and loaded carries are logged by time, not reps — with a work timer that counts the set itself (separate from the rest timer) and logs the time you actually held. They can carry weight too.
- Cardio: logged as time + speed, not weight × reps.
- Bodyweight exercises, logged as bodyweight: push-ups, pull-ups, dips and 300-odd others arrive knowing they carry no load — no weight column, no working-weight prompt, one stepper for the reps. Add a dip belt and it reads as an addition.
- Reps per side: for lunges, single-arm rows and the rest. You log the total, the app shows the split ("8 per side"), and targets step in twos so they never land on a number one side can't have.
- Rest per exercise: heavy triples and curls don't want the same break — give any exercise its own rest time and it overrides the global timer for that exercise. A superset rests once, taking the longest. Travels with shared plans.
- Share a plan: send someone your routines and week schedule as a small file (no workouts, no weigh-ins), or print it as a clean PDF. Importing merges — their own plan is never overwritten.
- Muscle preview: while you build a routine, the body diagram shows what it hits.
- Favourites: star an exercise in its details and it sorts first in the picker, the library and the muscle explorer; the picker gets a Favourites chip.
- kg ↔ lb, your numbers included: switching the unit asks whether to convert everything you've logged and planned — sets, working weights, targets and increments, warm-up configs, body weight, goal, bar weights — or keep the values and only change the label.
Progression — weights that follow a rule
- Pick a progression program per routine, overridable per exercise:
- Linear — add a fixed increment when you hit your reps.
- Greyskull LP — AMRAP top set, double jumps for beating the target big, 10 % resets on a stall.
- Double progression — through a visible rep range (both bounds editable); reps climb first, then the weight steps and reps reset. Per-side exercises step in twos.
- Timed progression — adding seconds instead of weight.
- Your weights are already right when the session opens, and every target says why it's that number. Missed reps never advance the load; stalls trigger a deload; bodyweight exercises progress in reps instead — and past a rep ceiling you set, a set is added instead of a rep, up to the point where the honest advice is load or a harder variation.
- Planned deloads: flag a routine as excluded from automatic progression — its sessions open with the routine's own target weights, stay in your history and statistics, and never become the baseline your next regular session progresses from.
Running a workout
- Guided sessions: today's routine opens with weights pre-filled from last time, animated demos, a rest timer, and PR detection as you log.
- A screen that gets out of the way. Each exercise has one ⋯ menu next to its name — note, details, progression, bar weight, warm-up set, superset with the previous or next exercise, swap, move, remove — and the set number is the set's own menu (drop set, rest-pause burst, remove). Nothing else sits between you and the numbers. Miss a button? Settings → During a workout → Workout controls brings any group back: +/− steppers, drop/burst shortcuts on every row, superset buttons in the header, move/swap/remove below the card.
- List view keeps the header pinned — name, clock, set counter, discard and finish stay at the top while the session scrolls.
- History without leaving the workout: ⋯ → History shows your best set (or estimated 1RM, longest hold, minutes) over time and the last ten sessions with every set, the volume and a PR marker. Also in an exercise's details in the library.
- It asks your body weight first — one habit, one chart.
- Plate math for barbell work. Barbell, olympic, EZ, trap bar and Smith machine exercises carry a bar weight — 20 kg / 45 lb and the rest of the usual markings, or your own number per exercise — and the set in front of you shows the split: Bar 20 kg · 30 kg per side. Tap it to change the bar mid-session. You still log the total, so history, progression and estimated 1RM keep meaning exactly what they always did.
- Plates per set: each set row can show the plates for its own weight, worked out from the plates you actually own.
- Pause the rest timer — paused time does not count, and the alert moves with it. On Android the countdown also sits in a notification, with Pause and ±15 s, and stays on time with the screen off.
- Animations are your call: the demo for each exercise can be full size, small, or hidden entirely during a workout (Settings → During a workout). Hidden collapses the media instead of leaving a gap.
- The screen stays awake while you train — on for as long as a workout runs, released the moment you finish, switchable off in Settings. (Needs HTTPS or localhost; iOS refuses the wake lock in Low Power Mode.)
- Change your mind mid-session: add an exercise you decided to do, or remove one you didn't, without ending the workout. Removing a member of a superset asks which one.
- Freestyle sessions: train without a plan and pick exercises as you go — each arrives prefilled from the last time you did it, same sets, same reps and weights by position.
- Log a past workout: forgot your phone, trained on paper, switched apps? Add a session after the fact from History — date, start time, duration, routine or freestyle, then the normal workout screen. If that day already has a workout you choose: replace, keep both, or cancel. Backfilled sessions never claim PRs against workouts that came later.
- Edit a saved workout: fix a weight, add a forgotten set or exercise, change the date, start time or duration — in the same screen you logged it on. Progression and records re-read the corrected session, and only the session you moved can earn a PR in its new place.
- Photos and videos on a workout (up to six) — a progress photo, a form check. Attach them on the finish screen or later in History. A saved workout can also be copied as text or saved as a routine.
- "Don't count for progression" for one exercise or a whole workout, when a session should not move your targets.
- Effort per set, in your scale: an optional third column rating how hard a set was, as RIR (reps left in the tank) or RPE (the same judgement on a 10-point scale). Tap the button to pick one of six levels, each with a sentence — "Nothing left, went to failure" through "Easy, warm-up territory" — or type an exact value; a logged rating tints its cell by how close to failure it was. Off by default; each set keeps the scale it was logged with, and nothing else reads the value — progression and 1RM are unaffected.
- See the timer end, not just hear it: an opt-in screen flash when a rest or work timer finishes — for loud gyms and headphones.
- Rest-timer push notifications even with the app closed (self-hosted; the mobile app uses native notifications).
Statistics
- Activity heatmap: a GitHub-style year view, shaded by time spent training.
- Estimated 1RM per exercise, from your best eligible set (it names which one), with its own progress curve and a calculator for sets you haven't done. It won't guess above 12 reps, and warm-up sets don't count.
- Muscle map, three ways — a front-and-back body diagram (male or female figure,
your pick) readable as:
- Balance — where the volume went, over a week, a month or all time, naming the muscles you haven't trained;
- Fatigue — what is still recovering, weighted by how close each set was to your maximum, decaying smoothly rather than expiring at a window edge;
- Strength — how long since you trained each muscle, and behind every one the exercises that built it with their estimated 1RM.
- Body-weight chart with a goal line you set — gains and losses colored by whether they move toward it.
- PRs detected as you train, listed per exercise.
- Structural Balance: compares your lifts with each other against the Poliquin, Thibaudeau and ATG ratios and names the weakest link.
Looks, languages & little things
- Light/dark themes and 8 accent colors, saved to your profile, over a hand-drawn icon set instead of emoji — it looks the same on every phone.
- 17 languages — EN, DE, ES, FR, IT, PT (Portugal), PT (Brazil), PL, TR, RU, UK, ZH, KO, HI, TH, HU, AR, with Arabic laid out right to left. Exercise instructions are localized in 12 of them, and built-in exercise names are translated in seven (PT-BR, HU, DE, ES, RU, IT, FR). Translations load on demand, so the app stays fast.
- kg or lb — pick your unit; imports convert.
- Workout-day reminder: an optional notification on days you have a workout planned but haven't logged one. It fires at your local time — each device reports its own timezone — and follows you if you travel.
- Installable PWA (self-hosted): add it to your home screen from any browser and it opens full-screen with its own icon.
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:
- Cloudflare Tunnel (no open ports): route
gym.example.com→http://<docker-host>:8080. HTTPS comes with it. - Caddy (automatic Let's Encrypt):
gym.example.com { reverse_proxy localhost:8080 } - Traefik / nginx / Nginx Proxy Manager: route the HTTPS hostname to
web:80or<docker-host>:8080. Any proxy works.
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).
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_ID | Hostname passkeys are bound to. A bare hostname: no scheme, no port, no slash. Default localhost. |
ORIGIN | Full URL the app is served from — with scheme, without trailing slash. Must match the address bar exactly. Default http://localhost:8080. |
RP_NAME | Name shown in the passkey prompt. Default openGym. |
WEB_PORT | Host port for the web UI. Default 8080. |
NGINX_PORT | Port the web container listens on, inside the container. Default 80. |
BACKEND | Name of the API service /api is proxied to — change it if yours isn't called api. |
PORT | Port the API listens on; the web container proxies to the same value. Default 3000. |
SESSION_DAYS | How long a sign-in lasts, in days. Default 90. |
ADMIN_UIDS | Comma-separated user ids that get the admin dashboard. Default: none — a fresh instance has no admin. |
INVITE_ONLY | Set 1 and new profiles need an invite code you generate from the dashboard. Existing accounts keep working. Default: off. |
ALLOW_GUEST | Set 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_LOG | Record sign-ins and admin actions to the activity log — set 0 to record nothing. Default: on. |
AUDIT_MAX | Events kept in the activity log; 0 for no limit. Default 5000. |
AUDIT_DAYS | Days kept in the activity log; 0 to keep until AUDIT_MAX. Default 90. |
AUDIT_IP | Record the caller's address: off, net (network only, e.g. 203.0.113.0/24) or full. Default off. |
PASSWORD_LOGIN | Set 1 to offer name-and-password sign-in next to passkeys. Default: off. |
TRUST_PROXY | Let 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_LANG | Language of the sign-in screen and of new profiles, e.g. de or pt-BR. Default: the visitor's browser language. |
BASE_PATH | Serve openGym under a subpath such as /gym, no trailing slash. Default: the site root. |
MEDIA_UPLOADS | Set 0 to turn off photo and video uploads (custom-exercise pictures, workout photos); the link field stays. Default: on. |
MEDIA_QUOTA_MB | Upload space per profile in MB; 0 for no cap. Default 200. Lower it on an instance with open signup. |
MEDIA_MIN_FREE_MB | Uploads 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_TARGET | Which API image to build: default, or coach to add the Claude Agent SDK and Codex CLI runtimes. API-key providers work on default. |
VAPID_SUBJECT | Contact URL sent with push notifications. Default: your ORIGIN. |
COACH_DISABLED | Set 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:
- In a browser that's already signed in: Settings → Pair the mobile app — shows a one-time code, valid 5 minutes.
- 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:
- 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.
- That payload goes to the provider you configured — there is no openGym service in the middle.
- 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.
- 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 Gemini | Paste an API key from the provider's console. Plain HTTPS from the api process — runs on the default image. |
| OpenAI-compatible endpoint | Any 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 CLI | Run 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 ~3B model at Q4 (like
qwen2.5:3b) fits a 4–6 GB container and answers a review in one to a few minutes once its cache is warm — a modern 4-core CPU sits at the fast end, an old low-power box at the slow one. - A 4B model needs ~6 GB+ for its container. Squeezed into less, it doesn't fail — it thrashes, generating at ~1 token/second. Smaller and comfortable beats bigger and swapping.
- The first request after a restart is slow once: the model loads and reads the Coach's rules. The api pre-warms that cache at boot and every half hour, so in practice people hit the warm path.
- On a genuinely slow box, raise the job budget with
COACH_JOB_TIMEOUT_MSin.env.
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_routines | What routines are saved? (names + exercise counts) |
get_routine | What does one routine prescribe, set by set? |
get_week_plan | This week's plan, including any date override. |
list_workouts | Recent sessions — dates, sets done/planned, volume, duration, PRs. |
get_workout | Full set-by-set breakdown of one session, by id or date. |
get_bodyweight | Weigh-ins, the goal line, deltas vs goal. |
estimate_1rm | Best 1RM for an exercise + trend, or a PR table across all. |
muscle_balance | Muscles 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 |
| Strong | Settings → Export Data |
| Hevy | Settings → Export & Import Data (workouts or measurements) — or skip the CSV entirely, below |
| Gravl | Profile → Export Data |
| Apple Health | Body-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.json | Profiles and public passkeys. Private keys never touch the server — they stay in your phone's secure hardware or password manager. |
state-<user>.json | One file per user: plan, workouts, body weight, settings. |
audit.log | The activity log (if on) — one JSON object per line. |
secret | The session-cookie signing key. |
vapid.json | Push-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:
- frontend/ — React 19 + Vite (React Router, Zustand), built to static files inside Docker. The app ships no runtime dependencies beyond React, the router and Zustand.
- api/ — Node with no framework and two dependencies
(
@simplewebauthn/serverfor passkeys,web-pushfor notifications), storing everything as plain JSON files under./data. - web/ — a multi-stage image that builds the frontend and serves it with nginx,
proxying
/apito the backend so it's all on one origin (passkeys require this).
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:
- Web / self-host:
docker compose up -d --buildbuilds the frontend inside the container — no local Node needed. - Android APK:
npm run build:mobileinfrontend/, then Gradle +zipalign+apksignerwith your own keystore (keep it — updates must carry the same signature). Step-by-step: docs/MOBILE.md. - iOS app: the Capacitor project in
frontend/ios— open in Xcode, set your (free) team, run on your device. Same doc. - Development: Node 20+;
npm testinfrontend/,api/andmcp/runs the suites. Contributions welcome — CONTRIBUTING.md.
Troubleshooting
Passkeys fail even though the config looks right
The most common support question — work through these in order:
- Ask the server what it actually loaded:
docker compose logs api | grep 'gym-api on'prints therpID/originit runs with. If that disagrees with your.env:docker compose restartdoes not re-read.env— usedocker compose up -d. - Check the exact shape:
RP_IDis a bare hostname (gym.example.com— nohttps://, no port, no slash);ORIGINis the full origin with scheme and without trailing slash. Both must match the address bar exactly. - 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.
www.is a different host. A passkey registered ongym.example.comwon't work onwww.gym.example.com— pick one, redirect the other.- Changing the hostname invalidates existing passkeys. Everyone registers again.
Everything else
| No passkey prompt on my phone | You'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 login | RP_ID/ORIGIN don't match the address bar — see the checklist above, starting with what the server logged. |
| Media didn't download | docker compose logs media, re-run docker compose up -d, or run ./scripts/fetch-media.sh. |
| Port 8080 already used | WEB_PORT=9090 in .env (and update ORIGIN for local testing). |
| No "Notifications" option in Settings | Needs a signed-in profile and HTTPS (or localhost) — guest mode and plain HTTP over LAN can't subscribe. |
| Day reminder fires at the wrong time | Toggle it off and on so it re-detects your browser's timezone (also happens on every app load). |
| "Keep screen awake" shows as unsupported | The Wake Lock API needs HTTPS or localhost; iOS also refuses it in Low Power Mode. |
| Want to reset a stuck login | Delete 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 build | Fixed 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
- The app collects nothing. No telemetry, no analytics, no crash reporting, no accounts on anyone else's server. The mobile app goes online only for exercise media from a CDN and, when you open Settings, for GitLab's public release list (the update check). In the browser, a self-hosted instance talks only to your own server.
- Passkey private keys never leave your device — the server stores only public keys.
- The optional activity log (self-hosted, admin feature) records sign-in events but no IP addresses, user-agents or failed-attempt passkey ids unless the operator opts in — details above.
- The AI Coach is opt-in twice over — an admin has to enable it, and a consent screen lists exactly which data categories leave the server before anyone uses it. Off, nothing is ever sent anywhere — details above.
- The MCP server is read-only, local, and never puts your data on the network.
- This website uses self-hosted, cookieless Umami page-view counting — no cookies, no cross-site tracking, and it never sees anything from inside the app or the demo.
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.