openGym is two small containers (a web server and an API) plus a folder of your data. This guide takes you from “just cloned it” to “using it from my phone over the internet”.
Requirements: Docker with the Compose plugin.
git clone https://github.com/DuarteSantos8/gym-app opengym
cd opengym
cp .env.example .env
docker compose pull # prebuilt images from ghcr.io (amd64 + arm64) — or skip and build from source
docker compose up -d
app/img and app/gif.docker compose pull and run
docker compose up -d --build instead — no Node needed locally either way.Check it’s healthy:
docker compose ps
curl http://localhost:8080/api/health # {"ok":true,...}
Logs: docker compose logs -f. Stop: docker compose down.
openGym signs you in with passkeys (WebAuthn). Browsers enforce two rules:
RP_ID).http://localhost.So http://localhost:8080 works on the machine running Docker, but another device (your
phone) cannot use http://<your-LAN-ip>:8080 — that’s neither localhost nor HTTPS, so the
passkey prompt won’t appear. To use openGym from your phone you need a real HTTPS hostname.
(You can still open it over LAN in guest mode, which stores data only in that browser.)
Put openGym behind something that terminates TLS for a hostname you control, then point it at
the web container. Pick whichever you already run:
gym.example.com → http://<docker-host>:8080.gym.example.com {
reverse_proxy localhost:8080
}
Route gym.example.com (HTTPS) → web:80 (or <docker-host>:8080). Any reverse proxy works —
openGym only needs the browser to reach it over https://gym.example.com.
Then set your domain in .env and restart:
# .env
RP_ID=gym.example.com
ORIGIN=https://gym.example.com
WEB_PORT=8080
RP_NAME=openGym
docker compose up -d
Visit https://gym.example.com, create your profile, and add it to your home screen
(iOS: Share → Add to Home Screen · Android: ⋮ → Add to Home screen).
Changing
RP_IDlater invalidates existing passkeys (they were bound to the old hostname). Pick your domain before people register.
Anyone who can reach the URL can create their own profile — each gets isolated data. That’s the default: open signup, no admin.
If you’d rather control who gets in, two optional settings in .env turn that around:
ADMIN_UIDS=youruserid # comma-separated; these users get the admin dashboard
INVITE_ONLY=1 # new profiles need an invite code
Register your own passkey profile first, then find your id in ./data/db.json under users[].id
and put it 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, the ability to disable an account (signed
out and locked out everywhere until you re-enable it), and — with INVITE_ONLY=1 — generating and
revoking invite codes. Existing accounts keep working when you switch invite-only on. Admin access
is gated by your passkey and enforced server-side, so it needs no separate login.
Prefer to keep the whole thing off the open internet? A VPN or an auth proxy (Authelia, Cloudflare Access…) in front still works, and composes with the above.
Everything is in ./data:
tar czf opengym-backup-$(date +%F).tar.gz data/
That archive contains all profiles, passkeys and workout history. Restore by unpacking it back into the project folder. (Individual users can also export their own data as JSON from Settings.)
openGym can push two kinds of alert to your phone/desktop, even when the app isn’t open: rest-timer-over, and a reminder on days you have a workout planned but haven’t logged one yet. Turn it on per-profile in Settings → Notifications (requires a signed-in passkey profile and HTTPS — see section 3).
No setup needed server-side, and nothing to configure per timezone: VAPID keys are generated on
first run and saved to ./data/vapid.json, and each user’s browser reports its own timezone
automatically when they turn the reminder on — it fires at their local time, and follows them if
they travel, regardless of what timezone the server itself runs in.
Keep screen awake (Settings → During a workout) has the same transport requirement: the
Wake Lock API is only available over HTTPS or on http://localhost, so on a plain-LAN-IP
instance the switch shows as unsupported. Nothing to configure server-side either way, and iOS
refuses the lock while the phone is in Low Power Mode.
Running prebuilt images:
git pull # picks up compose/config changes
docker compose pull
docker compose up -d
Building from source instead:
git pull
docker compose up -d --build
The app shell is versioned (?v=N) so clients pick up changes on next load. Your ./data and the
downloaded media are untouched.
| Symptom | Fix |
|---|---|
| No passkey prompt on my phone | You’re on http:// or an IP, not HTTPS. Set up a domain (section 3). |
| “verification failed” on login | RP_ID/ORIGIN don’t match the URL in the address bar. Make them exact, restart. |
| Media didn’t download | docker compose logs media. Re-run docker compose up -d, or run ./scripts/fetch-media.sh. |
| Port 8080 already used | Set WEB_PORT=9090 in .env (and update ORIGIN for local testing). |
| No “Notifications” option in Settings | Requires 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 in Settings so it re-detects your browser’s timezone (also happens automatically on every app load — see section 6). |
| Want to reset a stuck login | Delete the cookie in your browser; sessions are just signed cookies. |
docker compose pull fails with “denied” / “unauthorized” |
The prebuilt images aren’t published yet, or need to be, or the GHCR package is still private — build from source instead (docker compose up -d --build). |