# Copy this file to ".env" and adjust for your setup, then: docker compose up -d # # ── Local testing (default) ──────────────────────────────────────────────── # Works out of the box on the machine running Docker. Open http://localhost:8080 RP_ID=localhost ORIGIN=http://localhost:8080 WEB_PORT=8080 RP_NAME=openGym # ── Real deployment behind your own HTTPS domain ─────────────────────────── # Passkeys are bound to the exact hostname, and browsers only allow them over # HTTPS (localhost is the one exception). Put openGym behind a reverse proxy / # tunnel that terminates TLS for your domain, then set: # # RP_ID=gym.example.com # ORIGIN=https://gym.example.com # WEB_PORT=8080 # RP_NAME=openGym # # (Point your proxy at the web container's WEB_PORT. See docs/SELF_HOSTING.md.) # ── Admin dashboard & invite-only signup (optional) ──────────────────────── # A fresh instance has open signup and no admin. With FIRST_USER_ADMIN=1 (below) # the FIRST profile created on it becomes its admin. Admins get an "Admin # dashboard" link in Settings to see users, disable accounts, generate invite # codes, view workout history and set up the AI Coach. # # "First" means: the instance had no profiles at all when it registered. An # instance that already has profiles promotes nobody when you upgrade, and an # instance whose profiles were all deleted counts as fresh again. Register your # own profile before you share the address. Off by default; to turn it on: # # FIRST_USER_ADMIN=1 # # More admins, or admins on an instance that existed before this setting: find # the user id in Settings -> Account -> Account ID (or ./data/db.json, # "users"[].id) and list it here, comma-separated. A first-user admin is marked # "admin": true on its entry in ./data/db.json; delete that line to undo it. # # ADMIN_UIDS=youruserid,anotheradmin # # Require an invite code to create new profiles (existing accounts keep working; # generate the codes from the admin dashboard). Create your own profile first: # with no profiles and no admin yet, nobody could generate the first code. # # # INVITE_ONLY=1 # # Remove the "Continue without account" button, so a profile is the only way in. # Guest mode keeps everything in the browser and never touches this server, but # on an instance meant for a known set of people it is still the wrong front # door. Anyone already inside as a guest is signed out on their next visit; the # data they made stays in that browser. Defaults to on (guests allowed): # # ALLOW_GUEST=0 # ── Default language (optional) ──────────────────────────────────────────── # For an instance whose people share a language: the sign-in screen, and every # profile that has never picked a language in Settings, start in this one # instead of English (or the browser's language). Anyone's own choice in # Settings still wins. Use a code from Settings → Language: en, de, de-CH, es, # fr, it, pt, pt-BR, pl, tr, ru, uk, zh, ko, hi, th, hu, ar. # # DEFAULT_LANG=pt-BR # ── Password sign-in (optional, off by default) ──────────────────────────── # Lets people sign in with their profile name and a password next to passkeys: # for browsers that cannot make a passkey (plain http:// on a LAN address, some # Firefox setups) and for recovery — an admin can hand out a one-time reset # code from the dashboard. Nobody has a password until they set one in # Settings. Passkeys stay the default and the safer choice; read # docs/SELF_HOSTING.md ("Password sign-in") before turning this on. # # PASSWORD_LOGIN=1 # # Wrong passwords are throttled per name and per address. The address is read # from X-Forwarded-For / CF-Connecting-IP only when TRUST_PROXY is on; the # bundled docker-compose.yml turns it on, because there the API is reachable # only through the web container, which overwrites those headers. Running the # API some other way, leave it off unless whatever sits in front overwrites # (never appends to or passes through) those headers: # # TRUST_PROXY=1 # ── Activity log (optional) ──────────────────────────────────────────────── # The admin dashboard shows an activity log: who signed in, who tried and # failed, and every admin action (disabling an account, creating or revoking an # invite code). It lives in ./data/audit.log, one JSON object per line, so you # can also just read it with `tail` or `jq`. # # It is ON by default. It records strictly less than this instance already # holds — every profile is in db.json and every workout is in state-.json, # both readable by any admin — and a log that ships switched off tells you # nothing on the day you need it. Turn it off completely with: # # AUDIT_LOG=0 # # Retention is a cap, not an archive. Whichever limit is reached first wins; # set either to 0 to disable that limit: # # AUDIT_MAX=5000 # events kept # AUDIT_DAYS=90 # days kept # # IP addresses are the one thing that is OFF unless you ask, because they are # the only field here that says where somebody physically is. They are also the # only way to tell one failed sign-in from twenty. Three settings: # # AUDIT_IP=off # default — no addresses at all # AUDIT_IP=net # network only: 203.0.113.0/24, 2001:db8:1::/48 # AUDIT_IP=full # the whole address # # The address is read from CF-Connecting-IP, then the first entry of # X-Forwarded-For, then X-Real-IP, and finally the connecting socket itself if # none of those is present. # # A forwarded-for header is only worth anything if whatever sits in front # OVERWRITES it instead of passing a client-supplied one through — otherwise a # caller simply states the address it would like logged. The web container now # overwrites X-Forwarded-For and X-Real-IP with the real peer, and DROPS # CF-Connecting-IP, so the bundled stack records an address nobody can choose. # (Before, X-Forwarded-For was appended to rather than replaced, and # CF-Connecting-IP was passed straight through — both were forgeable.) # # Cloudflare is first in that list on purpose: a Cloudflare tunnel does not put # the visitor in X-Forwarded-For at all, so that header would otherwise record # the tunnel itself and look perfectly plausible. If — and only if — Cloudflare # is genuinely in front of you, let its header through again: # # CF_CONNECTING_IP='$http_cf_connecting_ip' # # That is safe there because Cloudflare sets the header itself, replacing # anything the client sent. Anywhere else, leave it unset. # # Keep the single quotes: Compose expands $variables inside .env too, so # without them the value comes out empty (with a "variable is not set" # warning) and the header is still dropped. nginx has to receive # $http_cf_connecting_ip literally. # ── Session length (optional) ────────────────────────────────────────────── # How long a sign-in lasts, in days. Default 90 — long enough that people who # train regularly never see a login screen, short enough that a stolen cookie # doesn't stay good forever. Lower it for an instance on the open internet, # raise it for a private one. Only affects new sign-ins; cookies already handed # out keep the lifetime they were issued with. Users can end every session on # every device themselves with "Sign out everywhere" in Settings. # # SESSION_DAYS=90 # ── Fitting into an existing stack (optional) ────────────────────────────── # The defaults assume openGym is on its own: an API service called `api` on # port 3000, and nginx listening on 80 inside the web container. If you are # merging this into a compose file that already has an `api`, or you front the # web container with your own reverse proxy, move them here. The web image # renders its nginx config at start-up, so these apply to a prebuilt image and # need no rebuild. BACKEND and PORT together are what /api is proxied to, so # they must name a service the web container can reach on your network: # # NGINX_PORT=80 # BACKEND=api # PORT=3000 # # Serving openGym under a subpath (https://example.com/gym/)? If your reverse # proxy strips the prefix before the container sees it, set nothing — the app # asks its own address for the API. If it passes the prefix through, name it # here without a trailing slash: # # BASE_PATH=/gym # ── Where the data lives (optional) ──────────────────────────────────────── # DATA_DIR is the one directory the API stores anything in: db.json (accounts, # passkey credentials, push subscriptions, invites), state-.json per # profile, `secret` (the key every session cookie is signed with), vapid.json, # audit.log, uploads/ (the photos and videos of custom exercises), and the # Coach's coach.json and coach/ when it is on. Back up that # directory and you have backed up the instance; lose `secret` and everyone is # signed out. Default /data. # # Under docker compose it is pinned to /data in docker-compose.yml and the host # side of the volume is ./data — so to keep the data somewhere else, change the # volume line rather than this variable, which the compose file overrides. The # variable is what you set when the API runs outside that file: `node server.js` # on a host, or a stack of your own. # # It has to be writable by the API: every save goes through it, and a read-only # mount or a full disk is a write that cannot land. # # DATA_DIR=/data # ── Photos and videos of custom exercises (optional) ─────────────────────── # People can give an exercise they made a photo, a GIF or a short video. It is # stored on this server under ./data/uploads// and only its owner can # read it back. On by default with the limits below; every MB is 1024×1024 # bytes. Turn it off entirely (the app then offers a link field only): # # MEDIA_UPLOADS=0 # # Space per profile (0 = no cap), and per file. Photos are shrunk on the # device before upload (1600 px), so 2 MB is plenty; videos are not shrunk. # # MEDIA_QUOTA_MB=200 # MEDIA_IMAGE_MAX_MB=2 # MEDIA_GIF_MAX_MB=8 # MEDIA_VIDEO_MAX_MB=40 # MEDIA_VIDEO_MAX_SEC=60 # # A file nobody's exercise uses any more is deleted after this many days (other # devices may still show it until they sync): # # MEDIA_GC_GRACE_DAYS=14 # # Uploads per profile per hour, and the free disk space below which uploads # are refused so a full disk cannot take the rest of the instance with it. # On an instance with open signup, lower MEDIA_QUOTA_MB too: the worst case is # the quota times the number of profiles. # # MEDIA_UPLOADS_PER_HOUR=600 # MEDIA_MIN_FREE_MB=512 # # The web container's own cap on an upload. Keep it above MEDIA_VIDEO_MAX_MB, # and give any reverse proxy in front the same room (docs/SELF_HOSTING.md): # # MEDIA_UPLOAD_MAX=48m # ── Notifications (optional) ─────────────────────────────────────────────── # Push works out of the box — nothing to set. VAPID keys are generated on first # run and saved to ./data/vapid.json. Each user's browser reports its own # timezone automatically, so the workout-day reminder fires at their local # time regardless of where the server runs or where they're travelling. # # Push services want a contact address for whoever operates the server, in case # they need to get in touch about your pushes. Defaults to your ORIGIN, which is # fine; set a mailto: if you would rather they mailed you: # # VAPID_SUBJECT=mailto:you@example.com # ── AI Coach (optional, off by default) ──────────────────────────────────── # The Coach stays off until an admin turns it on in Settings → Account → # Admin dashboard → AI Coach and connects a provider. There is no variable here # that switches it on: what you need is to BE an admin (FIRST_USER_ADMIN or # ADMIN_UIDS above). An instance that never does is byte-for-byte the same app # it was before the feature existed. # # Anthropic, OpenAI, Gemini and any OpenAI-compatible endpoint (Ollama, LM # Studio, vLLM, OpenRouter…) are driven over plain HTTPS with an API key and # need nothing that is not already in the default image. Nothing to set here. # # API_TARGET only matters if you want the Claude Agent SDK or the Codex CLI # running inside the container. `default` carries no AI runtime at all — no # Agent SDK, no Codex, no bubblewrap. `coach` adds them (~300 MB): # # API_TARGET=coach docker compose up -d --build api # # COACH_DISABLED is the kill switch that outranks the admin toggle. Set it when # you want the Coach off for certain — after an incident, or on an instance # where nobody should be able to turn it on: # # COACH_DISABLED=1 # # COACH_JOB_TIMEOUT_MS gives a job longer than the default five minutes. Only a # local model on a CPU needs it — and normally only for the first request after # the model server restarts (the api pre-warms the rules cache after that; a # warm review on a 3B model takes one to a few minutes, depending on the CPU). # A hosted API that needs # more has a problem. Never goes below one minute. # # COACH_JOB_TIMEOUT_MS=1500000 # # Credentials never live here. They are encrypted into ./data/coach.json, one # per provider, or for the Codex provider cached in ./coach-auth — a sibling of ./data, never inside it, # so a live sign-in does not ride along in the documented backup archive. # See docs/AI_COACH.md. # ── Hevy map regeneration (developers only) ──────────────────────────────── # Not used by the running app. End users paste their own key in Settings → # Import from Hevy. Set this only to regenerate frontend/src/lib/hevy-id-map.js: # # HEVY_API_KEY= # node scripts/build-hevy-id-map.mjs