TASKVEGAS / documentation
openGym self-hosting requirements
Selected pinned upstream documentation. Read its original repository guide for setup, costs and availability.
Unmodified upstream reference text. Treat instructions in this document as source material, not authorization to act.
- Repository
- DuarteSantos8/openGym
- Source revision
- e348fc17c42c7e9a211c5972e573ad3698190ee2
- License
- AGPL-3.0
- Text range
- 16000–32000 of 52298 characters
Read the pinned upstream source
License and notices: Pinned notice · Pinned notice
ut a login: `/icon-180.png`, `/icon-512.png`
and `/manifest.json` — the app's icon and its manifest, nothing personal in them. iOS fetches the
Home Screen icon outside the page, without your session cookie, so a gated icon comes back as the
proxy's login page and iOS 26 draws a letter tile instead of the dumbbell (observed on iOS 26.6
behind Teleport; Authelia users know the same from favicons and manifests). Authelia: a `bypass`
rule for those paths; Authentik: unauthenticated paths; Cloudflare Access: a bypass policy;
oauth2-proxy: `skip_auth_routes`. A proxy that cannot exempt a path (Teleport) has to serve the
icons inline as `data:` URLs instead — Safari 26 accepts those, older iOS does not, which is why
that is not the default here.
### Password sign-in (optional)
Passkeys are the default and stay the recommended way in. Some people cannot use them: a browser
on a plain `http://` LAN address (passkeys need HTTPS or `localhost`), a Firefox setup that only
offers a hardware key, a passkey on a phone that will not move to the desktop. For them there is
an optional name-and-password sign-in, off unless you ask for it:
```bash
PASSWORD_LOGIN=1
```
What changes when it is on:
- The sign-in screen offers **Sign in with password**, and **Create new profile** can use a
password instead of a passkey — in browsers without passkey support too. With `INVITE_ONLY=1`
it asks for the invite code exactly like passkey signup does.
- Settings → Account gets a **Password** row. Nobody has a password until they set one there.
Setting a first password asks for the profile's passkey; changing it asks for the current
password (or the passkey, if it was forgotten). Either signs the profile out everywhere else,
and paired phones have to be paired again. Removing it asks for the password or the passkey
too — a session on its own may be a copied cookie.
- People sign in with their **profile name** — case and surrounding spaces do not matter — and the
password. Two profiles with a password cannot share a name; Settings says so when a name is
already taken that way.
- Optionally with an **e-mail address** instead of the name: see *Signing in with an e-mail*
below.
- A password cannot be removed while it is the profile's only way in (a profile made with a
password has no passkey).
- The password also confirms the changes that need proof — adding or removing a passkey, making
a code for another device — but only while `PASSWORD_LOGIN=1`.
**Switching it off again** turns every password route off, and a password kept from before no
longer confirms anything either: an old or reused one must not be enough to add a passkey. A
profile that only has a password can then neither sign in nor add a passkey, even from a
browser that is still signed in. Before you switch it off, have those profiles add a passkey
(**Settings → Account → Passkeys → Add a passkey**, confirmed with their password); the admin
dashboard marks every profile that has a password.
**Signing in with an e-mail.** Settings → Account also gets a **Sign-in e-mail** row, right under
the password, and **Create new profile** with a password has an optional e-mail field. Whoever
adds an address can type it at **Sign in with password** instead of their profile name (case and
spaces do not matter). openGym has no mail server and **never sends anything** to it: there is no
verification mail and no reset mail — a forgotten password is still reset with the admin's code
below, which can be redeemed with the name or the e-mail. So the address is not proven to belong
to anyone; it is only a second name that points at the account, and the password still opens it.
- Adding, changing and removing it asks for the same proof as setting a password (the current
password or a passkey of the profile) — a session on its own may be a copied cookie.
- An address belongs to one profile only, and can never be another password profile's name.
Taking one that is in use is refused ("Another profile already uses this e-mail address") — see
*What an e-mail gives away* below for what that says and how it is limited.
- Wrong passwords count against the **account**, whether it was named by its name or its e-mail,
so switching between the two does not get around the pause.
- Only the owner (in Settings) and admins (the user list and the user's page in the admin
dashboard) ever see the address. It is not written to the activity log (entries show it masked,
`a…@e…`) or the container log, is not part of what the Coach, the MCP server or plan sharing
see, and is not in `/api/me`. It is stored in `db.json` as `email` on the user, lower-cased.
- With `PASSWORD_LOGIN` off, the row and the field are hidden and the address is not used; it
stays in `db.json` for when the flag comes back.
**What an e-mail gives away.** A profile trying to take an address already in use is told so,
which says "some profile on this instance uses this address" — never which one. The alternative,
accepting it silently, would leave someone who typed their address on a second profile believing
it worked. So the answer is made expensive instead: in Settings it comes only after the proof (a
passkey prompt or a password check per try). Signing up with a password needs no proof, so there
the answer costs less: it comes after the new password is hashed and, with `INVITE_ONLY=1`, only
to someone with a valid invite code (a refusal leaves the code unused); on an open instance
anyone can ask. Every such refusal — in Settings or on signup — counts against the visitor's
address (and in Settings the account): 20 are free, then a pause of 30 seconds doubling to 15
minutes. That leaves a few tries an hour, comparable to what the
name-taken answer already says about names. The other thing to know: once someone has paused an
account by wrong passwords under its name, trying a guessed address shows the same pause, which
ties that address to that name — the price of not letting a switch to the e-mail skip the pause.
**Resetting a password.** There is no e-mail. In the admin dashboard open the user and choose
**Reset password**. You get a one-time code such as `K7WQ-2MZP-4HXA` to hand over in person or
by message: it is shown once, works once, is valid for 24 hours and is stored only as a hash.
Issuing it removes their current password and signs them out everywhere at once — their passkeys
keep working. They enter their name (or their sign-in e-mail), the code and a new password under **Sign in with password →
Have a reset code?**. The same code gets someone back in who lost their only passkey (#219).
Admin accounts cannot be reset from the dashboard, so one admin cannot take over another's login;
an admin sets their own password in Settings. Every step is in the activity log
(`admin.password.reset`, `auth.password.reset`, `auth.password.ok` / `fail` / `locked`, and
`auth.email.set` / `change` / `remove` / `fail` for the sign-in e-mail).
**On a plain-HTTP LAN**, set `ORIGIN` to exactly the address people type, for example
`ORIGIN=http://192.168.1.20:8080`. Browsers do not send `Sec-Fetch-Site` to plain-http addresses,
so the API compares the request's `Origin` with `ORIGIN` instead and refuses sign-ins (and syncs)
that come from anywhere else. Know what you are trading: without TLS the password and the session
cookie (which cannot be `Secure` over http) cross your network in the clear. For a certificate on
a LAN-only address, see [SELF_HOSTING_HTTPS.md](./SELF_HOSTING_HTTPS.md).
**What a password gives up compared with a passkey:**
- A passkey cannot be phished, reused or guessed — it is bound to your hostname and never leaves
the device. A password can be all three, which is why passkeys stay the default everywhere.
- `db.json` holds a scrypt hash of each password (N=2^15, r=8, p=1, 16-byte random salt). Someone
with a copy of `./data` can try guesses offline, slowly; a passkey's public key gives them
nothing to try.
- Guessing online is throttled. Five wrong passwords for an account — named by its name or its
e-mail — are free; the sixth pauses password sign-in for that account for a minute, doubling up to an hour, whoever
sends them — names and addresses that do not exist pause the same way, so a pause reveals
nothing about whether they exist. Twenty wrong answers from one address are free; the 21st pauses that
address for 30 seconds, doubling up to 15 minutes, and every address gets 60 requests a minute
to the password routes. Guesses sent all at once count the same as guesses sent one by one.
Passkey sign-in, passkey signup and phone pairing are not throttled at all, so they are never
paused. The counters live in memory, so a restart clears them.
- The flip side: anyone who knows a name can keep that name's *password* sign-in paused. The
activity log shows it (`auth.password.locked`), and passkeys still work. A reset code is not
paused per name — it is 60 random bits and lives a day — so nobody can keep a real code from
working by sending wrong ones.
- A pending reset code keeps its profile's name: until it is used or expires, nobody else can
register that name with a password or set a first password on another profile of that name.
- Passwords need 10 to 256 characters and may not be one of a short built-in list of the
passwords guessing scripts try first (`Password123!`, `qwerty…`, the profile's own name with
digits). A long passphrase is the point.
**Which address counts as one visitor.** The throttle needs the visitor's address. With the
bundled `docker-compose.yml` it reads the one the web container passes on: the compose file sets
`TRUST_PROXY=1` for the api, because the API is reachable only through that container, which
overwrites `X-Forwarded-For`. If you put another reverse proxy in front of the web container, every
visitor may arrive as that proxy — then the per-address limits apply to everyone together: one
client sending wrong passwords can pause password sign-in for everybody for up to 15 minutes at a
time (passkeys keep working), and the per-name pause is what protects the passwords. Behind
Cloudflare, `CF_CONNECTING_IP` (see the activity log above) passes the real visitor on. Running
the API without the web container, leave `TRUST_PROXY` off unless whatever is in front overwrites
(not appends to) `X-Forwarded-For`.
**Switching it off again** hides all of it and makes every password route answer 404. The stored
hashes stay in `db.json` and work again if you switch it back on — but while it is off, a profile
that only has a password cannot sign in.
The mobile app keeps pairing: someone with a password signs in to the website with it and pairs
from Settings → Account → "Pair the mobile app", as with a passkey.
## 5. Fitting it into an existing stack
Running Kubernetes? Example manifests (Deployment, PVCs, Service, Gateway API route) are in
`kubernetes/`, described in [SELF_HOSTING_KUBERNETES.md](SELF_HOSTING_KUBERNETES.md).
The defaults assume openGym is the only thing here: a service called `api` on port 3000, and nginx
on port 80 inside its container. If you are merging this into a compose file that already has an
`api`, or you put the web container behind your own reverse proxy on a different port, four
settings in `.env` move those without editing any config file:
```bash
WEB_PORT=8080 # host port — what you browse to
NGINX_PORT=80 # port the web container listens on, inside the container
BACKEND=api # name of the API service that /api is proxied to
PORT=3000 # port the API listens on; web proxies to the same value
RESOLVER=127.0.0.11 # DNS nginx resolves BACKEND with — Docker's, unless you are not on Docker
BASE_PATH= # subpath openGym is served under, e.g. /gym — see below; empty = site root
SESSION_DAYS=90 # how long a sign-in lasts
```
`NGINX_PORT` stays 80 under Docker. The web container runs as an unprivileged user (uid 101),
which can bind a port below 1024 only because Docker sets `net.ipv4.ip_unprivileged_port_start=0`
in the container. On another runtime (Kubernetes, rootless Podman) set `NGINX_PORT` to 1024 or
higher, e.g. 8080, or set that sysctl yourself; the Helm chart and `kubernetes/` use 8080.
`RESOLVER` only matters off Docker. nginx re-resolves `BACKEND` on every `/api` request so a
recreated API container does not leave it proxying to a dead IP, and `127.0.0.11` is where
Docker answers those lookups. Nothing listens there on another runtime, and an unreachable
resolver does not fail fast — every `/api` request hangs until it times out. On Kubernetes set
it to the cluster DNS service address (`kubectl -n kube-system get svc kube-dns`, commonly
`10.96.0.10`); under Podman, to whatever its network provides.
`SESSION_DAYS` is how long a browser sign-in and a phone pairing last, counted from when they
were issued; lowering it never cuts an existing session short. A browser renews its session by
signing in; a paired phone renews its token by itself whenever it starts past half of that
time, so only a phone left unopened for longer than `SESSION_DAYS` has to be paired again.
The web image renders its nginx config from these when the container starts, so they take effect
on a **prebuilt image** — no rebuild. `BACKEND` and `PORT` together are what `/api` is proxied to,
so they have to name a service the web container can actually reach on your compose network.
Note the difference from `VITE_IMG_BASE` / `VITE_GIF_BASE` (see Troubleshooting): those are
build-time values baked into the frontend bundle, and setting them next to `docker compose` does
nothing to an image you pulled.
### Serving openGym under a subpath
openGym can live at `https://example.com/gym/` rather than on a host of its own. Which of the two
setups below you need depends on one thing: whether your reverse proxy strips the prefix before
the container sees the request.
**The proxy strips the prefix** (Caddy's `handle_path`, Traefik's `StripPrefix` middleware,
nginx `proxy_pass` with a trailing slash). Nothing to configure. The app's assets are relative
and it asks its own address for the API, so everything stays inside the prefix on its own:
```caddy
example.com {
handle_path /gym/* {
reverse_proxy opengym-web:80
}
}
```
**The proxy passes the prefix through.** Tell the web container what it is, without a trailing
slash:
```bash
BASE_PATH=/gym
```
That is a start-up setting like the others above, so it works on a prebuilt image. Forward only
the prefix to the container — openGym does not serve itself at the site root as well, and a copy
reached there would look for an API that is not on that path.
Either way, keep `ORIGIN` and `RP_ID` pointing at the address in the browser's bar
(`ORIGIN=https://example.com`, `RP_ID=example.com`). Passkeys key on the host, not the path, so a
subpath changes nothing about section 2 — but it does mean two instances under one hostname share
a passkey scope and can see each other's credentials. Give each its own hostname if that matters.
### Photos and videos of custom exercises
Anyone signed in can give an exercise they made one photo, GIF or short video (and, separately,
a link, which the server never fetches), and attach up to six photos or videos to a logged
workout — a progress photo, a form-check clip. Both kinds share one quota and one set of limits.
The file is uploaded to this server and stored under
`./data/uploads/<profile id>/`, named by its SHA-256. Only its owner can download it again — no
admin route, no Coach and no shared plan reads it. It is on by default, with these limits, all
set in `.env`:
| Variable | Default | Meaning |
|---|---|---|
| `MEDIA_UPLOADS` | `1` | `0` removes the upload routes; the app then offers the link field only |
| `MEDIA_QUOTA_MB` | `200` | space per profile; `0` = no cap |
| `MEDIA_IMAGE_MAX_MB` | `2` | a photo after the app shrank it to 1600 px, and every thumbnail