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.
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:latestOnly 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).
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_PATHguard, the exercise media, and the CSP string.
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.
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).
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.
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:
- Loads the tarball and asserts the image ID matches the one in
meta.json - Runs
smoke-test.shagain on the loaded bytes, because they travelled through an artifact store - Tags and pushes
<ver>andlatest, verifies both digests match - Creates the git source tag (
v<ver>, or-<n>for a rebuild) - 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=1234567890The 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: 25on the build, 20 on the publish.- Serialized runs:
build-<ref>for builds,publishfor publishes, both withcancel-in-progress: false, so:latestand the numbered tag series never race. - Dependabot opens weekly PRs for new action majors; patch and minor updates arrive through the major tags.
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_TOKENstarts no other workflow runs, so theopengymbuild 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.
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> |
The daily check does steps 1 to 4 and opens a PR, merging that PR is step 5. By hand:
- Read upstream's release notes and confirm both image tags exist
- Edit
ARG UPSTREAM_VERSION. - Re-vendor
NOTICE.mdfrom the new tag; re-copyLICENSEif upstream changed it. - Build and run
smoke-test.shlocally. - Commit and push to
master; CI publishes<ver>+latest.
- 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 getsv<ver>, and each rebuild getsv<ver>-<n>, numbered from the remote's tags. Inside the registry,image.createdandimage.revisionstill 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.mdships inside the image and is re-vendored on every release update.
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. |
- Map a host path to
/data(chowned to uid 99 at boot). - Set
ORIGIN, plusRP_IDfor passkeys, to the address people will use. - Open the WebUI and create a profile.
- Healthy once nginx answers and
/api/healthcomes back through the proxy.
- One image, two supervised processes. Upstream's compose runs
apiandwebas 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 distronginxaccount. 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.