taskvegas.

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
0–16000 of 52298 characters

Read the pinned upstream source

License and notices: Pinned notice · Pinned notice

# Self-hosting openGym

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".

**On this page**

1. [Run it locally](#1-run-it-locally-5-minutes) — five minutes, nothing else needed
2. [The passkey requirement](#2-understand-the-passkey-requirement-important) — why your phone needs HTTPS
3. [HTTPS on your own domain](#3-expose-it-over-https-on-your-own-domain) — Cloudflare Tunnel, Caddy, Traefik, nginx
4. [Multiple users](#4-multiple-users) — admin dashboard, invites, language, activity log, password sign-in
5. [Fitting into an existing stack](#5-fitting-it-into-an-existing-stack) — ports, subpaths, media uploads
6. [Backups](#6-backups)
7. [Notifications](#7-notifications)
8. [Updating](#8-updating)
- [Passkeys fail even though `RP_ID` looks right](#passkeys-fail-even-though-rp_id-looks-right)
- [Which passkey providers work](#which-passkey-providers-work)
- [Troubleshooting](#troubleshooting)

HTTPS on a LAN without a public domain has [its own guide](SELF_HOSTING_HTTPS.md), and so does
[Kubernetes](SELF_HOSTING_KUBERNETES.md). Short answers to common questions are in the [FAQ](FAQ.md).

## 1. Run it locally (5 minutes)

Requirements: [Docker](https://docs.docker.com/get-docker/) with the Compose plugin.

```bash
git clone https://github.com/DuarteSantos8/openGym   # or https://gitlab.com/DuarteSantos8/opengym — same repo
cd openGym
cp .env.example .env
docker compose pull   # prebuilt images from ghcr.io (amd64 + arm64; the same images are on GitLab's registry) — or skip and build from source
docker compose up -d
```

- The exercise stills and animations (180 px, ~80 MB) ship inside the web image; nothing is downloaded on first start. An old `media/` folder and its two volume lines from before v1.4.0 are no longer used: harmless if you keep them, safe to delete.
- Open **http://localhost:8080** and create a profile with a passkey.
- Rather build from source than pull prebuilt images? Skip `docker compose pull` and run
  `docker compose up -d --build` instead — no Node needed locally either way.

Check it's healthy:

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

Logs: `docker compose logs -f`. Stop: `docker compose down`.

## 2. Understand the passkey requirement (important)

openGym signs you in with **passkeys** (WebAuthn). Browsers enforce two rules:

1. Passkeys are bound to an exact **hostname** (`RP_ID`).
2. They only work over **HTTPS** — with one exception: `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 — or
turn on **password sign-in**, see [§4](#password-sign-in-optional).)

The standalone mobile app (`docs/MOBILE.md`) sidesteps this entirely for its "connect to my
server" mode: instead of a passkey ceremony (impossible from inside its WebView, which never
runs at your real hostname), it pairs by redeeming a short one-time code — minted from
Settings → Account → "Pair the mobile app" in an already signed-in browser tab — for a bearer token
sent as an `Authorization` header rather than a cookie. Two consequences worth knowing if
you're poking at the API directly:

- `POST /api/pair/create` (needs a session) and `POST /api/pair/redeem` (doesn't) implement
  this — see `api/server.js`. The returned token is the exact same signed value a cookie
  carries, so "sign out everywhere" invalidates it too — and a phone has no passkey to sign
  back in with, so every paired phone has to be **paired again** afterwards. The phone says
  so ("Your server no longer accepts this phone") and keeps its data until it is.
- The token lasts `SESSION_DAYS` like a cookie, but renews itself: `GET /api/me`, which the
  app calls on every start, answers a bearer token past half its lifetime with a fresh
  `token` carrying the account's current session version. Revocation is unaffected.
- The server reflects `Access-Control-Allow-Origin` for any request that sends an `Origin`
  header, so the app's own WebView origin can call the API cross-origin. It never sends
  `Access-Control-Allow-Credentials`, so this doesn't let a browser read your cookie session
  from another origin — only bearer-token requests benefit from it.

## 3. Expose it over HTTPS on your own domain

> Want HTTPS **without** exposing anything to the internet — a valid certificate on a LAN-only address? See [SELF_HOSTING_HTTPS.md](./SELF_HOSTING_HTTPS.md) (wildcard cert via a DNS challenge, Caddy in front).

Put openGym behind something that terminates TLS for a hostname you control, then point it at
the `web` container. Pick whichever you already run:

### Option A — Cloudflare Tunnel (no open ports)

1. Create a tunnel and route `gym.example.com` → `http://<docker-host>:8080`.
2. Cloudflare gives you HTTPS automatically.

### Option B — Caddy (automatic Let's Encrypt)

```caddy
gym.example.com {
    reverse_proxy localhost:8080
}
```

### Option C — Traefik / nginx / Nginx Proxy Manager

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`. If that proxy caps
request bodies (nginx does, at 1 MiB by default), allow at least 5 MiB on `/api/` — the app syncs
its whole history in one PUT; the bundled web image already allows 5 MiB, matching the API. The
photos and videos people attach to their own exercises need more room and more time on
`/api/media/` — see [Photos and videos of custom exercises](#photos-and-videos-of-custom-exercises).

Then set your domain in `.env` and restart:

```bash
# .env
RP_ID=gym.example.com
ORIGIN=https://gym.example.com
WEB_PORT=8080
RP_NAME=openGym
```

```bash
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_ID` later invalidates existing passkeys (they were bound to the old hostname).
> Pick your domain before people register.

#### Phone app and CORS

The browser app is served from your domain, so it never makes a cross-origin request. The
[phone app](MOBILE.md#connecting-the-app-to-your-own-server) does: its WebView runs at
`https://localhost` (Android) or `capacitor://localhost` (iOS), and the browser engine asks your
server first (a CORS preflight, `OPTIONS`) before every request with a JSON body or a token.
**openGym answers that preflight itself**: it allows the requesting origin, the methods
`GET, POST, PUT, DELETE, OPTIONS` and the headers `Content-Type, Authorization` — so the proxy only
has to pass it through unchanged.

Don't put a CORS middleware on the openGym route. A Traefik `headers` middleware with
`accessControlAllowOriginList` (or nginx `add_header Access-Control-*` with an `if
($request_method = OPTIONS) { return 204; }`) answers the preflight on its own and never forwards
it; when the app's origin is not on its list, the phone refuses to send the real request and
pairing fails while the openGym containers log nothing. In Traefik's access log the preflight then
looks like this:

```
"OPTIONS /api/pair/redeem HTTP/2.0" 200 0 "https://localhost/" "Mozilla/5.0 (Linux; Android 10; K; wv) ..." 16011 "websecure-opengym_rtr@file" "-" 0ms
```

The signature is status `200` with backend `"-"` and `0ms`: the router matched, but no service
was asked and no time was spent. When openGym answers it, you see `204`, the openGym service
name and a few milliseconds. If the middleware has to stay (it is shared with other routes), add
`https://localhost` and `capacitor://localhost` to the allowed origins, the methods and headers
above to its lists, and don't set allow-credentials (the app authenticates with a bearer token,
never a cookie).

The same thing breaks in other shapes:

- **A second `Access-Control-Allow-Origin`.** openGym already sends one. A proxy that adds its
  own (Nginx Proxy Manager's "Advanced" tab or a custom location with `add_header
  Access-Control-Allow-Origin ...`, a Caddy `header` line, a Traefik middleware) makes it two,
  and the browser engine refuses a response with two, even when both say the same origin.
  Remove the proxy's copy.
- **A rewritten or stripped header.** A proxy that sets the header to a fixed origin (your
  domain, or `*` together with credentials) or drops the `Access-Control-*` headers from
  openGym's answer refuses the app just the same. Pass them through untouched.
- **SSO or forward-auth in front of `/api/`.** Authelia, Authentik, oauth2-proxy, Cloudflare
  Access or basic auth answer the app with a redirect to a login page or a `401`, and the
  preflight never carries cookies, so it can't log in. openGym has its own sign-in (passkeys,
  passwords and the app's bearer token), so exclude `/api/` from proxy auth. Keep the auth on the
  rest of the site if you like; the phone app only talks to `/api/`.

Check it yourself from any machine; this is the preflight the phone sends:

```bash
curl -si -X OPTIONS https://HOST/api/pair/redeem \
  -H 'Origin: https://localhost' \
  -H 'Access-Control-Request-Method: POST' \
  -H 'Access-Control-Request-Headers: content-type'
```

Expect `HTTP/2 204` (or `HTTP/1.1 204`) with exactly one `access-control-allow-origin:
https://localhost` line. A `200` with no CORS headers, a `30x` or `401`, an HTML page or two
`access-control-allow-origin` lines mean the proxy is in the way.

The app tells these cases apart since v1.3.10: when the server is reachable but the request is
refused, pairing says so instead of "Failed to fetch", and when a login page or proxy rule
answered in openGym's place, it says that too. Since v1.3.11 the Android app also pairs with a
plain `http://` address on your LAN (type the `http://`; #428). Sign in on the web with a password
to make the code, since passkeys need https. Over http the code and the app's token cross your
network unencrypted, so keep it to a network you trust.

## 4. Multiple users

Anyone who can reach the URL can create their own profile — each gets isolated data. That's the
default: open signup. With `FIRST_USER_ADMIN=1` in `.env`, **the first profile created on the
instance becomes its admin**, so a fresh install needs no `ADMIN_UIDS` edit. It is off by default,
because an existing instance with no profiles yet would hand admin to whoever signs up first; if
you turn it on, register your own profile before you share the address.

"First" is decided at the moment of registration: the instance had no profiles at all. It is
never applied after the fact — an instance that already has profiles promotes nobody when it is
upgraded — and an instance whose profiles were all deleted counts as fresh again. The flag is
stored as `"admin": true` on that profile's entry in `./data/db.json`; delete that line (with the
stack stopped) to take it back.

If you'd rather control who gets in, these optional settings in `.env` turn that around:

```bash
ADMIN_UIDS=youruserid      # comma-separated; these users get the admin dashboard too
INVITE_ONLY=1              # new profiles need an invite code
ALLOW_GUEST=0              # remove "Continue without account"
FIRST_USER_ADMIN=1         # the first profile on an empty instance becomes its admin
```

Create your own profile before you switch `INVITE_ONLY` on: with no profile and no admin yet,
nobody could generate the first code.

For more admins (or on an instance that existed before `FIRST_USER_ADMIN`), copy the id from
**Settings → Account → Account ID** (tap it to copy; it is also in `./data/db.json` under
`users[].id`) and put it in `ADMIN_UIDS`. The same row is how anyone on your instance tells you
which account is theirs when they need help. Admins get an **Admin dashboard** link in Settings → Account: 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.

### Default language

An instance whose people share a language can start everyone in it:

```bash
DEFAULT_LANG=pt-BR         # any code from Settings → Units & language → Language: de, es, fr, pt-BR, …
```

The sign-in and create-profile screens open in that language, and so does every profile that
has never picked one in **Settings → Units & language → Language**. Anyone's own choice there always wins, and
profiles that existed before this setting keep the language they have — the app cannot tell an
old profile that chose English from one that never looked, so it leaves them alone. Without
`DEFAULT_LANG`, a new visitor starts in their browser's language when openGym has it, else
English. A code the app has no translation for is ignored. The phone app in local mode has no
server to ask and is unaffected.

Until someone picks a language, it is worked out on each device and not saved with the profile:
without `DEFAULT_LANG`, the same profile can show in German on one phone and in English on a
laptop, and the Coach answers in whichever the app is showing. A scheduled review, which no app
asks for, is written in `DEFAULT_LANG` when it is set.

### The activity log

The dashboard also keeps an **activity log**: sign-ins, sign-outs, failed attempts, refused
signups, and every admin action (disabling an account, creating or revoking an invite code). It
lives in `./data/audit.log` as one JSON object per line, so `tail -f data/audit.log` and `jq`
work on it directly, and the dashboard reads the same file.

It is on by default and keeps the last 5,000 events or 90 days, whichever comes first
(`AUDIT_LOG=0` turns it off entirely; `AUDIT_MAX` and `AUDIT_DAYS` change the caps). **IP
addresses are not recorded** unless you set `AUDIT_IP=net` (network only, e.g. `203.0.113.0/24`)
or `AUDIT_IP=full`. Neither the browser's user-agent nor the passkey id of a failed sign-in is
ever stored: the first is a fingerprint, and the second would let you follow an unknown device
from one attempt to the next.

Two things worth expecting. **Guests never appear** — guest mode never talks to the server, so
there is nothing to log, exactly as there is nothing for the rest of the dashboard to show. And
**a disabled account goes quiet**: a disabled user is refused at the session check, so their only
entries are the failed sign-ins they keep making.

Clearing the log from the dashboard records the clear itself, and the event ids keep counting, so
a gap is always visible.

`INVITE_ONLY=1` and `ALLOW_GUEST=0` answer different questions and are usually set together.
Invite-only controls who may *create a profile*; it says nothing about the **Continue without
account** button, which never creates one. Guest mode keeps everything in that browser and never
talks to the server — there is no account, no sync and nothing for the admin dashboard to show —
so on an instance meant for a known set of people it is a door that leads nowhere useful. With
`ALLOW_GUEST=0` the button is gone, and anyone already using the app as a guest is returned to the
login screen on their next visit. Their data is not deleted: it stays in that browser and comes
back if you ever switch guests on again, or moves into a real profile if they create one on the
same device.

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.

Behind an auth proxy, let three files through witho

Read the next section

Search documentation