Skip to content
0x1917Public

About

Single-container build of OpenGym, designed for Unraid.

Topics

Resources

Contributing

Security policy

Stars

1 star

Watchers

0 watching

Forks

Latest commit

 

History

2 Commits

Folders and files

Repository files navigation

OpenGym

Version Build Lint Pulls Image size Platform License

Single-container build of OpenGym: Node API, nginx-served SPA, and exercise media in one image, supervised by s6-overlay. Built for Unraid - install it with the OpenGym template from 0x1917/unraid-templates.

Run

Prebuilt on Docker Hub: docker pull 0x1917/opengym:latest.

docker run -d --name opengym --restart unless-stopped   \
  -p 8310:80                                            \
  -e ORIGIN=http://{IP}}:8310                           \
  -v /mnt/user/appdata/opengym:/data                    \
  0x1917/opengym:latest

Only port 80 is published. nginx fronts everything and proxies /api to the loopback API, so the app stays on a single origin (a passkey requirement).

How the image is built

Nothing is compiled here. The Dockerfile starts from upstream's published images (ghcr.io/duartesantos8/opengym-api and opengym-web), both at the release named by ARG UPSTREAM_VERSION, and adds the supervision layer: s6-overlay, uid-99 ownership, an unprivileged nginx master, and the boot-time config rendering they need.

  • A build is layer copies, one apk add (nginx,gettext, tzdata) and the sha256-verified s6-overlay tarballs.
  • The upstream web image supplies the built SPA, the nginx site template, the BASE_PATH guard, the exercise media, and the CSP string.

Build

The build context is this directory, so run it from the repository root.

docker build -t opengym:dev .

The image carries three provenance labels: image.revision (default=dev), image.created (default=unknown), and image.base.digest (default=unknown). The CI chain fills them from the commit and from the pinned base image.

Test a different upstream release without editing the file

docker build -t opengym:dev                                              \
  --build-arg OPENGYM_API_IMAGE=ghcr.io/duartesantos8/opengym-api:<ver>  \
  --build-arg OPENGYM_WEB_IMAGE=ghcr.io/duartesantos8/opengym-web:<ver>  \
  .

<ver> is an upstream release tag. Override both refs together: nothing in the Dockerfile cross-checks that the API and web tags come from the same release. For a real update, edit the ARG instead (see Version management).

Test

scripts/smoke-test.sh <image> is the gate CI runs. It boots the image on a free loopback port with a throwaway data dir and fails on the first broken expectation. Needs docker, curl, jq, and openssl.

bash scripts/smoke-test.sh opengym:test
Check Proves
boots, /api/health ok through nginx both supervised services came up, proxy chain intact
SPA responds with a CSP header the web tier serves with upstream's policy
exercise media served the media copy from the web image landed at the served path
npm absent from the image the runtime-free upstream target shipped, no npm tree
API not running as root the uid-99 drop in s6-api-run
license texts ship (AGPL + notice) a dropped COPY builds and boots fine, so nothing else notices
PASSWORD_LOGIN reaches the config endpoint image env surface and template rows agree
password signup + session cookie the API can create a user and issue a session through the proxy
first user is administrator FIRST_USER_ADMIN behaves as the template claims
state written through the proxy a real write path, not only a read
/data owned 99:100 the Unraid appdata ownership contract
docker stop exits 0 no orphaned nginx, no swallowed signal
replacement keeps session + data, BASE_PATH=/gym an upgrade does not lose data, and subpath mode serves
invalid BASE_PATH refused at boot misconfiguration fails loudly instead of 404-ing

Deliberate gaps: passkey ceremonies (needs a real browser), Coach provider jobs, TLS and reverse-proxy behaviour, and anything requiring real user data.

CI

Two workflows, chained. Neither one can do the other's job.

Workflow Trigger Does
build.yml push to master, PR, dispatch on the same paths build, smoke test, and package the image as an artifact
publish.yml workflow_run after build completes download that run's artifact, verify it, push, tag, and cut the release

Build never touches the registry. Publish never builds. The image crosses the boundary as docker save | zstd plus a meta.json holding the image ID, the version, and the publish decision.

Event Build Publish
push to master touching a build input yes yes
push to master touching only the workflows yes no
pull request yes no
dispatch, force_publish=false yes no
dispatch, force_publish=true yes yes

Publish runs only from master, only after a green build, and only when the bundle says so. It then:

  1. Loads the tarball and asserts the image ID matches the one in meta.json
  2. Runs smoke-test.sh again on the loaded bytes, because they travelled through an artifact store
  3. Tags and pushes <ver> and latest, verifies both digests match
  4. Creates the git source tag (v<ver>, or -<n> for a rebuild)
  5. Creates the GitHub release, whose notes quote upstream's changelog section for that version word for word, and state the digest that shipped

A rebuild moves that release onto the numbered source tag rather than opening a second release, so each version keeps one release whose tag names the bytes it was last cut from.

Two manual paths:

# build now, publish on completion (one click, then the chain does the rest)
gh workflow run build.yml --ref master -f force_publish=true

# republish an existing build, no rebuild (run id from the Actions URL)
gh workflow run publish.yml -f build_run_id=1234567890

The first marks the bundle as publishable and lets the chained workflow push it. A dispatch without the input builds and smokes only. The second re-pushes the bytes from that build run - it no-ops the source tag if the commit already has one and it still refuses if the bundle was never marked as publishable.

What matters:

  • The published image is the tested image. The artifact carries the exact image ID the gate ran against and the publish side refuses to proceed if the ID it loads differs.
  • Publishing is gated on the image inputs. Everything in this repo counts as a build input except the CI scripts, the assets, and this README, so a new file defaults to publishing rather than silently skipping.
  • timeout-minutes: 25 on the build, 20 on the publish.
  • Serialized runs: build-<ref> for builds, publish for publishes, both with cancel-in-progress: false, so :latest and the numbered tag series never race.
  • Dependabot opens weekly PRs for new action majors; patch and minor updates arrive through the major tags.

Upstream releases

scout (.github/workflows/scout.yml) runs daily, on demand, and on pull requests that touch the checker script or that workflow (check only, since a PR has no write token). It compares two upstreams: OpenGym's images (ARG UPSTREAM_VERSION) and s6-overlay (ARG S6_OVERLAY_VERSION plus four tarball checksums, re-read from the release's own .sha256 assets). The logic is scripts/upstream-check.sh (check and apply), which needs only curl and jq and therefore also runs by hand. When something newer exists it updates the ARGs, re-vendors NOTICE.md, builds, runs the smoke gate, and only then opens a PR. Nothing publishes until that PR is merged.

  • A PR opened with the built-in GITHUB_TOKEN starts no other workflow runs, so the opengym build workflow does not appear on it. Build and smoke evidence lives in the check run's log and the PR body.
  • The branch name carries the date and both versions, so a re-run on the same day is a no-op while the PR is open. Close it unmerged and the next run proposes it again.

Version management

ARG UPSTREAM_VERSION is the single source of truth. Both image refs derive from it, so one edit cannot leave the pair mismatched.

<ver> below is an upstream release tag such as 1.0.1. The published version is whatever the ARG says.

Thing Form Example
upstream image tag bare semver (upstream's own convention) opengym-api:<ver>
this repo's Hub tag bare semver, same as upstream 0x1917/opengym:<ver>
OCI image.version label bare semver <ver>
git tag (CI) v + version; rebuilds get -<n> v<ver>

Update checklist

The daily check does steps 1 to 4 and opens a PR, merging that PR is step 5. By hand:

  1. Read upstream's release notes and confirm both image tags exist
  2. Edit ARG UPSTREAM_VERSION.
  3. Re-vendor NOTICE.md from the new tag; re-copy LICENSE if upstream changed it.
  4. Build and run smoke-test.sh locally.
  5. Commit and push to master; CI publishes <ver> + latest.

Caveats

  • A rebuild reuses the Hub tag. <ver> is overwritten because Hub tags are mutable. The git side records every build: the first publish of a release gets v<ver>, and each rebuild gets v<ver>-<n>, numbered from the remote's tags. Inside the registry, image.created and image.revision still separate two builds of one release.
  • Rollback is a previous release tag (0x1917/opengym:<ver>) or a local image. Old tags are not deleted.
  • The media is not AGPL. Upstream's notice permits a non-commercial OpenGym fork to carry the unmodified 180px media with the notice, which is why NOTICE.md ships inside the image and is re-vendored on every release update.

Environment

Deploy-time variables, the same fields the Unraid template exposes:

Variable Default Meaning
ORIGIN (required) Exact address users type, scheme included. A mismatch refuses sign-in with origin-mismatch.
RP_ID (empty) Hostname from ORIGIN, no scheme or port. Required for passkeys; changing it invalidates them. Passkeys need HTTPS; on plain HTTP use PASSWORD_LOGIN.
PASSWORD_LOGIN true Adds name and password sign-in (upstream default: off). Registration stays a passkey ceremony.
FIRST_USER_ADMIN true First profile on an empty instance becomes admin (upstream default: off).
ALLOW_GUEST true false removes "Continue without account".
INVITE_ONLY false true gates sign-ups behind an invite code. Create an admin profile first.
TRUST_PROXY true Honor X-Forwarded-For and X-Real-IP; set when a reverse proxy fronts the container.
ADMIN_UIDS (empty) Comma-separated account IDs (Settings > Account > Account ID) with admin dashboard access.
BASE_PATH (empty) Subpath deployments (/gym, no trailing slash). Empty means site root.
CF_CONNECTING_IP (empty) $http_cf_connecting_ip, only when Cloudflare is really in front; empty drops the header.
MEDIA_UPLOAD_MAX 48m Largest upload nginx accepts (client_max_body_size). Raise it for big media imports; suffix k, m, or g.
TZ etc/UTC Container timezone for logs and the audit log. Reminders fire in each user's own zone regardless.

First boot

  1. Map a host path to /data (chowned to uid 99 at boot).
  2. Set ORIGIN, plus RP_ID for passkeys, to the address people will use.
  3. Open the WebUI and create a profile.
  4. Healthy once nginx answers and /api/health comes back through the proxy.

Design notes

  • One image, two supervised processes. Upstream's compose runs api and web as separate containers; merging them removes container-to-container DNS from the request path and leaves one artifact to build, back up and roll back.
  • s6-overlay, sha256-verified at build. pid-1 signal handling, privilege dropping, ordered one-shot init.
  • Crash policy. A crashed service restarts after 1s; five deaths in 60s end the CMD so Docker's restart policy owns the failure. A failed stage 2 stops the boot (S6_BEHAVIOUR_IF_STAGE2_FAILS=2).
  • Both services unprivileged. API as uid 99 (group users, Unraid's appdata convention), nginx as the distro nginx account. Only the API can read /data.
  • License texts ship in the image at /usr/share/opengym/: the AGPL text (LICENSE) and upstream's third-party notice (NOTICE.md). The image redistributes upstream's build and the separately licensed exercise media, so the notice is a condition of carrying that media, not a formality.

About

Single-container build of OpenGym, designed for Unraid.

Topics

Resources

Contributing

Security policy

Stars

1 star

Watchers

0 watching

Forks

Releases

Sponsor this project

Packages

Used by

Contributors

Languages