Docs
The complete reference — every feature, every setting, every way to run MeuPlano Fit. The same documents live in the repository, next to the code they describe.
Which MeuPlano Fit? The three flavors
One codebase, three ways to run it. Everything below applies to all of them unless a section says otherwise.
| Mobile app | The App Store app for iPhone (with the Apple Watch app) and the Android APK. No account, no server, no sync unless you want one: the phone is the account, data lives in the app's private storage, reminders are native notifications. Install and done, or connect it to your server later. |
| Self-hosted | Two Docker containers on your own machine. Adds passkey profiles, sync across devices, installable as a home-screen PWA, optional admin dashboard, web push notifications. The flavor for households and training groups. |
| Demo | The in-browser demo — the real app seeded with example data, storing everything in your browser only. Useful to try before installing, and as a free plan editor (see Import). |
You can start with the mobile app and move to a server later (or the other way round) — the backup file migrates everything, and the mobile app can also connect directly to your server.
Install on Android
There are three APKs, and they are the same app; only the exercise animations built in differ. Standard has them at 180 px and stays small, Plus at 240 px, HD at 360 px: sharper on big screens, heavier on your storage. Not sure? Take Standard. Changed your mind later? Install another one on top: same signing key, your data stays.
- Open the download on your phone and tap Standard (or Plus or HD).
- Your browser will ask whether it may install apps. Allow it: that is how any app from outside the Play Store gets installed (sideloading), and you can take the permission back right after.
- Open the downloaded
MeuPlano Fit-<version>.apk, confirm, done. No account, no setup.
Every release puts all three APKs on the
GitHub release
for that version, each with a .sha256 checksum beside it. Got the file from
anywhere else? Check it against that checksum before installing.
Updates come to you: when a newer release exists, Settings → About & updates shows "MeuPlano Fit vX available". One tap downloads the signed APK, checks its SHA-256 against the published checksum and opens the installer (no checksum, no install; the first time Android asks for the "install unknown apps" permission). Each APK updates to its own kind (Standard to Standard, Plus to Plus, HD to HD), so nobody gets surprised by a download twice the size. Everything is stored on the phone itself; the animations are inside the app. The app goes online for one thing only: when you open Settings → About & updates, it looks at the public release list on GitHub to see whether a newer version exists.
The app requests notification permission only when you switch the workout-day reminder
on, and declares SCHEDULE_EXACT_ALARM so reminders fire to the minute.
Reminders are scheduled per calendar date — a day you already trained, or rescheduled
away, stays quiet. The missed-workout nudge, when switched on, is scheduled the same way.
Use on iPhone
MeuPlano Fit has a native iPhone and iPad app, with an Apple Watch app, widgets, a Live Activity and Apple Health. It is the same app as on Android: use it on its own or connect it to your server.
- The App Store: get it here, in every App Store country. To try the next version early, the public TestFlight beta is the same app.
- Self-host + home screen: open your own MeuPlano Fit instance in Safari → Share → Add to Home Screen. A full-screen app with its own icon, passkey sign-in and sync.
- Build it yourself (a fork, say): open
frontend/iosin Xcode with a free Apple ID and run it on your phone (Apple expires that signature after 7 days). See docs/MOBILE.md. - Just looking? Try the in-browser demo: the real app with example data, no account and nothing to install.
The app, feature by feature
What's actually inside, grouped the way you'll meet it. All of it ships in every flavor.
Home & the weekly plan
- A routine per weekday. The home screen knows what day it is, shows this week with what's done and what's planned, and starts today's session with one tap.
- See what a session hits. Today's session on Home and every routine on the Plan page (the week, the rotation and the routine list) carry a tiny body, front and back, with the muscles it works lit up. No more opening Leg Day to check that it is, in fact, legs.
- Reschedule any day. Sick, missed a session, fewer gym days this week? Move a workout to another date without touching the weekly plan itself — the override applies to that date only.
- Weeks start where you say. Monday or Sunday, in Settings → Plan & schedule. The weekly plan, the day strip on Home, the calendar grid and every “this week” total — the workout count, the week streak, the seven-day muscle balance — all follow it. Existing profiles keep the Monday week they have been looking at.
- On a rest day it says what’s next. Rather than a full stop, the Home row names the next day that actually has something to train, and which routine it is. Rescheduled days count; a routine with no exercises in it does not.
- Body weight on the home screen: your latest weigh-in, the trend, and how far you are from the goal you set.
- Streaks: consecutive training weeks, workouts this week, and totals.
Routines & the plan builder
- 5,632 exercises: searchable (typos and gym shorthand welcome), with animated demonstrations on a male or female figure, a short description and step-by-step instructions, plus a filter for the equipment you actually own (the filter options adapt to what you've picked, so every combination on screen has results).
- Your own exercises: a name and a body part is enough; they behave like built-in ones everywhere, with an optional description instead of an animation.
- Your own picture on your own exercises: give an exercise you made a photo, a GIF or a short video, plus a link to a video or guide. The file is shrunk and stripped of its location data on the device before it is uploaded; signed in, it syncs with your profile and still shows offline.
- Replace an exercise in the routine editor and keep its sets, reps and weight.
- Supersets: plan them into a routine, or pair two exercises mid-session with "make superset with previous/next" — then work through the group back-to-back with a single rest at the end of each round. Unpair any time; a group of one dissolves itself.
- Warm-up sets: mark the ramp-up rows as warm-ups and they stay out of every number that shouldn't see them — no effect on estimated 1RM, progression, or the fatigue map, while still being right there in the session. A weight change cascades down the rows that share its phase, not across the divide.
- Timed exercises: planks, hangs, wall sits and loaded carries are logged by time, not reps — with a work timer that counts the set itself (separate from the rest timer) and logs the time you actually held. They can carry weight too.
- Cardio: logged as time + speed, not weight × reps.
- Bodyweight exercises, logged as bodyweight: push-ups, pull-ups, dips and 300-odd others arrive knowing they carry no load — no weight column, no working-weight prompt, one stepper for the reps. Add a dip belt and it reads as an addition.
- Reps per side: for lunges, single-arm rows and the rest. You log the total, the app shows the split ("8 per side"), and targets step in twos so they never land on a number one side can't have.
- Rest per exercise: heavy triples and curls don't want the same break — give any exercise its own rest time and it overrides the global timer for that exercise. A superset rests once, taking the longest. Travels with shared plans.
- Share a plan: send someone your routines and week schedule as a small file (no workouts, no weigh-ins), or print it as a clean PDF. Importing merges — their own plan is never overwritten.
- Muscle preview: while you build a routine, the body diagram shows what it hits.
- Favourites: star an exercise in its details and it sorts first in the picker, the library and the muscle explorer; the picker gets a Favourites chip.
- kg ↔ lb, your numbers included: switching the unit asks whether to convert everything you've logged and planned — sets, working weights, targets and increments, warm-up configs, body weight, goal, bar weights — or keep the values and only change the label.
Progression — weights that follow a rule
- Pick a progression program per routine, overridable per exercise:
- Linear — add a fixed increment when you hit your reps.
- Greyskull LP — AMRAP top set, double jumps for beating the target big, 10 % resets on a stall.
- Double progression — through a visible rep range (both bounds editable); reps climb first, then the weight steps and reps reset. Per-side exercises step in twos.
- Timed progression — adding seconds instead of weight.
- Your weights are already right when the session opens, and every target says why it's that number. Missed reps never advance the load; stalls trigger a deload; bodyweight exercises progress in reps instead — and past a rep ceiling you set, a set is added instead of a rep, up to the point where the honest advice is load or a harder variation.
- Planned deloads: flag a routine as excluded from automatic progression — its sessions open with the routine's own target weights, stay in your history and statistics, and never become the baseline your next regular session progresses from.
Running a workout
- Guided sessions: today's routine opens with weights pre-filled from last time, animated demos, a rest timer, and PR detection as you log.
- A screen that gets out of the way. Each exercise has one ⋯ menu next to its name — note, details, progression, bar weight, warm-up set, superset with the previous or next exercise, swap, move, remove — and the set number is the set's own menu (drop set, rest-pause burst, remove). Nothing else sits between you and the numbers. Miss a button? Settings → Workout → Fine-tuning brings any group back: +/− steppers, drop/burst shortcuts on every row, superset buttons in the header, move/swap/remove below the card.
- List view keeps the header pinned — name, clock, set counter, discard and finish stay at the top while the session scrolls.
- History without leaving the workout: ⋯ → History shows your best set (or estimated 1RM, longest hold, minutes) over time and the last ten sessions with every set, the volume and a PR marker. Also in an exercise's details in the library.
- It asks your body weight first — one habit, one chart.
- Plate math for barbell work. Barbell, olympic, EZ, trap bar and Smith machine exercises carry a bar weight — 20 kg / 45 lb and the rest of the usual markings, or your own number per exercise — and the set in front of you shows the split: Bar 20 kg · 30 kg per side. Tap it to change the bar mid-session. You still log the total, so history, progression and estimated 1RM keep meaning exactly what they always did.
- Plates per set: each set row can show the plates for its own weight, worked out from the plates you actually own.
- Pause the rest timer — paused time does not count, and the alert moves with it. On Android the countdown also sits in a notification, with Pause and ±15 s, and stays on time with the screen off.
- Animations are your call: the demo for each exercise can be full size, small, or hidden entirely during a workout (Settings → Workout). Hidden collapses the media instead of leaving a gap.
- The screen stays awake while you train — on for as long as a workout runs, released the moment you finish, switchable off in Settings → Workout. (Needs HTTPS or localhost; iOS refuses the wake lock in Low Power Mode.)
- Change your mind mid-session: add an exercise you decided to do, or remove one you didn't, without ending the workout. Removing a member of a superset asks which one.
- Freestyle sessions: train without a plan and pick exercises as you go — each arrives prefilled from the last time you did it, same sets, same reps and weights by position.
- Log a past workout: forgot your phone, trained on paper, switched apps? Add a session after the fact from History — date, start time, duration, routine or freestyle, then the normal workout screen. If that day already has a workout you choose: replace, keep both, or cancel. Backfilled sessions never claim PRs against workouts that came later.
- Edit a saved workout: fix a weight, add a forgotten set or exercise, change the date, start time or duration — in the same screen you logged it on. Progression and records re-read the corrected session, and only the session you moved can earn a PR in its new place.
- Photos and videos on a workout (up to six) — a progress photo, a form check. Attach them on the finish screen or later in History. A saved workout can also be copied as text or saved as a routine.
- "Don't count for progression" for one exercise or a whole workout, when a session should not move your targets.
- Effort per set, in your scale: an optional third column rating how hard a set was, as RIR (reps left in the tank) or RPE (the same judgement on a 10-point scale). Tap the button to pick one of six levels, each with a sentence — "Nothing left, went to failure" through "Easy, warm-up territory" — or type an exact value; a logged rating tints its cell by how close to failure it was. Off by default; each set keeps the scale it was logged with, and nothing else reads the value — progression and 1RM are unaffected.
- See the timer end, not just hear it: an opt-in screen flash when a rest or work timer finishes — for loud gyms and headphones.
- Rest-timer push notifications even with the app closed (self-hosted; the mobile app uses native notifications).
Statistics
- Activity heatmap: a GitHub-style year view, shaded by time spent training.
- Estimated 1RM per exercise, from your best eligible set (it names which one), with its own progress curve and a calculator for sets you haven't done. It won't guess above 12 reps, and warm-up sets don't count.
- Muscle map, three ways — a front-and-back body diagram (male or female figure,
your pick) readable as:
- Balance — where the volume went, over a week, a month or all time, naming the muscles you haven't trained;
- Fatigue — what is still recovering, weighted by how close each set was to your maximum, decaying smoothly rather than expiring at a window edge;
- Strength — how long since you trained each muscle, and behind every one the exercises that built it with their estimated 1RM.
An explicit muscle credit of 0 retains the association metadata but contributes no effective sets, fatigue, per-muscle strength stimulus, last-trained recency or body-map training intensity for that muscle. This is separate from logging an exercise with zero load. Secondary muscle credit remains 0.4 when no override is specified.
- Body-weight chart with a goal line you set — gains and losses colored by whether they move toward it.
- PRs detected as you train, listed per exercise.
- Structural Balance: compares your lifts with each other against the Poliquin, Thibaudeau and ATG ratios and names the weakest link.
Looks, languages & little things
- Light/dark themes, 8 accent colors or one of your own, saved to your profile, over a hand-drawn icon set instead of emoji, so it looks the same on every phone.
- 19 languages: EN, DE, ES, FR, IT, PT (Portugal), PT (Brazil), PL, TR, RU, UK, ZH, ZH-TW, KO, HI, BN, TH, HU, AR, with Arabic laid out right to left. Exercise instructions are localized in 12 of them, and built-in exercise names are translated in seven (PT-BR, HU, DE, ES, RU, IT, FR). Translations load on demand, so the app stays fast.
- kg or lb — pick your unit; imports convert.
- Workout-day reminder: an optional notification on days you have a workout planned but haven't logged one. It fires at your local time — each device reports its own timezone — and follows you if you travel.
- Missed-workout nudge: opt-in, under the reminder. Skip a planned workout and you get one nudge that evening, in the tone you picked: Friendly, Guilt trip (“I miss you”) or Drill sergeant. Rest days stay quiet, and after three missed days in a row it stops until your next workout.
- Gym reminder (phone app, off by default): add your gyms in Settings → Reminders → Gym reminder, from where you stand or as a pin on a map, each with its own radius and text. Walk into one and the phone nudges you to hit Start: once a day at most, never after you trained, never mid-workout, and only between the hours you pick if you like. Your gyms stay on the phone (not on your server, not in backups). Android uses the system's own proximity alerts, no Google services needed; it can be slow to notice you arrived, so with MeuPlano Fit's battery use on Unrestricted a short GPS look near the gym ("Quick arrival check") nudges you at the door instead. A workout you logged on your laptop that the phone hasn't synced yet counts too: a paired phone asks your server first.
- Settings that slide: a settings page slides in from the right and back out, like the iPhone's own Settings. On the iPhone a swipe from the left edge goes back.
- Installable PWA (self-hosted): add it to your home screen from any browser and it opens full-screen with its own icon.
Self-host with Docker
The self-hosted flavor is two small containers (web + API) and a folder of your data. You need Docker with Compose:
git clone https://github.com/DuarteSantos8/openGym
cd MeuPlano Fit
cp .env.example .env
docker compose pull # prebuilt images (amd64 + arm64) — skip to build from source
docker compose up -d
Open http://localhost:8080, tap Create profile, and you're in. The
exercise pictures and animations are part of the web image, so there's nothing else to
download. Check it's healthy:
docker compose ps
curl http://localhost:8080/api/health # {"ok":true,...}
Logs: docker compose logs -f · stop: docker compose down ·
build from source instead of pulling: skip the pull and run
docker compose up -d --build — no Node needed locally either way.
The passkey rule (read this before opening a port)
MeuPlano Fit signs you in with passkeys (WebAuthn — Face ID, fingerprint, or your
password manager). Browsers enforce two rules: a passkey is bound to an exact
hostname, and it only works over HTTPS — with one exception,
http://localhost.
So http://localhost:8080 works on the Docker machine, but your phone
cannot use http://192.168.x.x:8080 — that's neither localhost nor
HTTPS, and the passkey prompt won't appear. To use MeuPlano Fit from your phone you need a
real HTTPS hostname (below). Until then, LAN devices can still use guest mode
(data stays in that browser), password sign-in
(PASSWORD_LOGIN=1) or the mobile app's pairing
mode. No fingerprint reader on your laptop is a different story and needs none of
that: a passkey can sit on your phone (scan the browser's QR code), in a password manager
or on a security key.
HTTPS on your own domain
Put anything that terminates TLS in front of the web container. Pick
whichever you already run:
- Cloudflare Tunnel (no open ports): route
gym.example.com→http://<docker-host>:8080. HTTPS comes with it. - Caddy (automatic Let's Encrypt):
gym.example.com { reverse_proxy localhost:8080 } - Traefik / nginx / Nginx Proxy Manager: route the HTTPS hostname to
web:80or<docker-host>:8080. Any proxy works. - Tailscale Serve (no domain, nothing on the internet):
tailscale serve --bg http://127.0.0.1:8080gives the host ahttps://<name>.<tailnet>.ts.netaddress with a real certificate; use that name asRP_ID/ORIGIN. Phones need the Tailscale app. Steps in SELF_HOSTING.md.
Then set the domain in .env and apply it:
# .env
RP_ID=gym.example.com
ORIGIN=https://gym.example.com
docker compose up -d # restart alone does NOT re-read .env
Visit your domain, create your profile, add it to the home screen (iOS: Share → Add to Home Screen · Android: ⋮ → Add to Home screen).
RP_ID later invalidates existing passkeys — they
were bound to the old hostname, and everyone registers again. Settle the domain before
people join.Want a valid certificate without exposing anything to the internet — HTTPS on a LAN-only address? That works too (wildcard cert via a DNS challenge, Caddy in front): docs/SELF_HOSTING_HTTPS.md. A VPN or an auth proxy (Authelia, Cloudflare Access…) in front composes with all of this.
Notifications
A self-hosted instance can push two kinds of alert to your phone or desktop, even with
the app closed: rest timer over, and the workout-day reminder. Turn them on
per profile in Settings → Reminders (needs a signed-in profile and HTTPS).
Nothing to configure server-side: VAPID keys are generated on first run and saved to
./data/vapid.json, and each browser reports its own timezone. Set
VAPID_SUBJECT=mailto:you@example.com if you'd rather push services could
reach an inbox than your origin URL.
Where it works: any desktop browser, Android Chrome, and on iOS only the app added to the Home Screen (a Safari tab has no Web Push). The Android APK doesn't use Web Push — its reminder is a local notification scheduled on the phone. A reminder that was due while the server was down or restarting is still sent up to 15 minutes late, once; the browser re-registers its subscription with the server on every signed-in start, so one the server lost heals itself; and a rest-timer alert belongs to the device that started the rest, so finishing a rest on the desktop no longer silences the phone.
The optional missed-workout nudge rides on the reminder: one push between 20:00 and 21:30 (or from 2 hours after a late reminder) on a planned day with nothing logged and no workout on screen, at most three missed days in a row, then quiet until the next workout.
Sync between devices
Signed in, the profile on the server is the truth. Signing in on a device adopts it — settings, plan, history — and if that device logged workouts while signed out, one dialog asks whether to add them to the profile or keep the profile as it is. While the app is open it asks the server every 30 seconds (and every time it comes back to the foreground) whether anything changed and fetches only what did, so a session logged on the phone shows up on the desktop within half a minute. Two devices that both changed something are merged on the server's revision — every workout, routine and weigh-in from both sides is kept, settings follow the newer copy — instead of one overwriting the other. Without a network the app keeps working on the device and says so under the header; the changes go up when the connection is back.
Fitting it into an existing stack
The defaults assume MeuPlano Fit is alone: a service called api on port 3000,
nginx on 80 inside the web container. Merging into a compose file that already has an
api, or fronting it with your own proxy on another port, is four
.env settings — WEB_PORT, NGINX_PORT,
BACKEND, PORT. The web image renders its nginx config from
these at container start, so they work on a prebuilt image, no rebuild.
Under a subpath such as https://example.com/gym/, with nginx passing
the prefix through:
location = /gym { return 301 /gym/; }
location /gym/ {
proxy_pass http://127.0.0.1:8080; # no trailing slash
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
client_max_body_size 48m;
proxy_read_timeout 300s;
}
# .env
BASE_PATH=/gym
RP_ID=example.com
ORIGIN=https://example.com # no /gym here
Prefer proxy_pass http://127.0.0.1:8080/; (trailing slash, prefix stripped)?
Then leave BASE_PATH empty and add proxy_redirect / /gym/;.
Stripping and setting BASE_PATH loads the page but 404s every API call.
And skip proxy_cookie_path: over https the session cookie is a
__Host- cookie, which browsers drop when its path isn't /, so you'd
sign up and still not be signed in. Both setups are in
SELF_HOSTING.md.
On a NAS (Synology, QNAP, Unraid, TrueNAS)
Works like any Docker host, with four catches. Run both services: a stack with only
web loads, but sign-in never works and Continue without account quietly
makes you a guest whose data stays in that browser. Use absolute paths for the data
volume and env_file (for example /share/Container/meuplano/.env), because
the NAS's compose UI keeps the file in a folder of its own. Set ORIGIN to the
exact address you type, port included (ORIGIN=http://10.11.0.4:3030,
RP_ID=10.11.0.4), then recreate the stack; otherwise every save is refused and the
API log's refused cross-origin line names the values to set. Add HTTPS
through the NAS's reverse proxy (Synology Login Portal, QNAP Reverse Proxy, Nginx Proxy
Manager or Caddy on Unraid and TrueNAS) for passkeys. The api container runs as
root and writes data/, so an NFS share with root squash needs a different owner.
The full walkthrough with a compose file to paste is in
SELF_HOSTING.md.
Updating
git pull # picks up compose/config changes
docker compose pull # (or: docker compose up -d --build, if building from source)
docker compose up -d
Your ./data and the downloaded media are untouched; the app shell is
versioned so clients pick up changes on next load. Releases are announced on
GitHub
and Discord; container images
(amd64 + arm64) are published to
GitLab's registry
and mirrored to GHCR
on every release.
Every setting
All configuration is one .env file (see .env.example); the
defaults are what you get if you touch nothing.
RP_ID | Hostname passkeys are bound to. A bare hostname: no scheme, no port, no slash. Default localhost. |
ORIGIN | Full URL the app is served from — with scheme, without trailing slash. Must match the address bar exactly. Default http://localhost:8080. |
RP_NAME | Name shown in the passkey prompt. Default MeuPlano Fit. |
WEB_PORT | Host port for the web UI. Default 8080. |
NGINX_PORT | Port the web container listens on, inside the container. Default 80. |
BACKEND | Name of the API service /api is proxied to — change it if yours isn't called api. |
PORT | Port the API listens on; the web container proxies to the same value. Default 3000. |
SESSION_DAYS | How long a sign-in lasts, in days. Default 90. |
ADMIN_UIDS | Comma-separated user ids that get the admin dashboard (your id: Settings → Account → Account ID). Default: none, so a fresh instance has no admin. |
FIRST_USER_ADMIN | Set 1 and the first profile created on an instance with no profiles becomes its admin. Has no effect once profiles exist. Default: off. |
INVITE_ONLY | Set 1 and new profiles need an invite code you generate from the dashboard. Existing accounts keep working. Default: off. |
ALLOW_GUEST | Set 0 to remove Continue without account. Guest data isn't deleted — it stays in that browser and returns if you re-enable guests, or moves into a profile created on the same device. Default: on. |
AUDIT_LOG | Record sign-ins and admin actions to the activity log — set 0 to record nothing. Default: on. |
AUDIT_MAX | Events kept in the activity log; 0 for no limit. Default 5000. |
AUDIT_DAYS | Days kept in the activity log; 0 to keep until AUDIT_MAX. Default 90. |
AUDIT_IP | Record the caller's address: off, net (network only, e.g. 203.0.113.0/24) or full. Default off. |
PASSWORD_LOGIN | Set 1 to offer name-and-password sign-in next to passkeys. Default: off. |
TRUST_PROXY | Let the sign-in throttle read the visitor's address from proxy headers. 1 in docker-compose.yml, where the web container is the only way to the API; leave it off if you run the API without it. |
DEFAULT_LANG | Language of the sign-in screen and of new profiles, e.g. de or pt-BR. Default: the visitor's browser language. |
BASE_PATH | Serve MeuPlano Fit under a subpath such as /gym, no trailing slash, when your proxy passes that prefix through. A proxy that strips it needs this empty. Default: the site root. See existing stack. |
MEDIA_UPLOADS | Set 0 to turn off photo and video uploads (custom-exercise pictures, workout photos); the link field stays. Default: on. |
MEDIA_QUOTA_MB | Upload space per profile in MB; 0 for no cap. Default 200. Lower it on an instance with open signup. |
MEDIA_MIN_FREE_MB | Uploads are refused while the disk under ./data has less free space than this. Default 512. Size limits per file type are in SELF_HOSTING.md. |
API_TARGET | Which API image to build: default, or coach to add the Claude Agent SDK and Codex CLI runtimes. API-key providers work on default. |
VAPID_SUBJECT | Contact URL sent with push notifications. Default: your ORIGIN. |
COACH_DISABLED | Set 1 to switch the AI Coach off harder than any toggle. COACH_JOB_TIMEOUT_MS raises the job budget for slow local models. |
Two things that look like settings but aren't: DATA_DIR is pinned to
/data by docker-compose.yml and mapped to ./data —
change the host side of that volume, not the variable. And
VITE_IMG_BASE/VITE_GIF_BASE are build-time values baked
into the frontend bundle — setting them next to docker compose does nothing
to a prebuilt image (see Troubleshooting).
Multiple users, admin & the activity log
Anyone who can reach the URL can create their own profile — each gets isolated data. That's the default: open signup, no admin. To control who gets in:
FIRST_USER_ADMIN=1 # the first profile on an empty instance becomes admin
ADMIN_UIDS=youruserid # or name admins by id: Settings → Account → Account ID
INVITE_ONLY=1 # new profiles need an invite code
ALLOW_GUEST=0 # remove "Continue without account"
On a brand-new instance, set FIRST_USER_ADMIN=1 before anyone signs up and
register your own profile first. Already registered? FIRST_USER_ADMIN only ever
applies to the very first profile, so copy your id from Settings → Account → Account
ID into ADMIN_UIDS, run docker compose up -d (a plain
restart keeps the old .env) and reload the app. You'll get an
Admin dashboard link in Settings → Account (it's also where the AI
Coach is switched on): who's
training right now, each user's workout history and body weight, disabling an account
(signed out and locked out everywhere until re-enabled), and — with invite-only on —
generating and revoking invite codes. Admin access rides on your passkey and is enforced
server-side; there is no separate admin login.
Signing in: passkeys, more devices, passwords
A profile can hold up to 20 passkeys — one per phone, laptop or password manager — listed in Settings → Account. A new device without a synced passkey joins with a one-time code or QR from a device that is already signed in. Sign out everywhere ends every session and unpairs phones at once.
For people who can't use passkeys (a plain http:// LAN address, a browser that
only offers a hardware key) an instance can switch on name-and-password sign-in with
PASSWORD_LOGIN=1. It is off by default; nobody has a password until they set
one in Settings, the sign-in routes are throttled, and an admin can hand out a one-time
reset code. The details are in
SELF_HOSTING.md.
INVITE_ONLY and ALLOW_GUEST answer different questions and are
usually set together: invite-only controls who may create a profile; the guest
button never creates one — guest mode keeps everything in that browser and never talks
to the server at all.
The activity log
The dashboard keeps an activity log: sign-ins, sign-outs, failed attempts, refused
signups, and every admin action. It lives in ./data/audit.log as one JSON
object per line — tail -f and jq work on it directly, and the
dashboard reads the same file. On by default, keeping the last 5,000 events or 90 days,
whichever comes first.
What it deliberately does not record: IP addresses (unless you opt in via
AUDIT_IP), browser user-agents (a fingerprint), and the passkey id of a
failed sign-in (it would let you follow an unknown device between attempts). Guests
never appear — nothing they do touches the server. Clearing the log records the clear
itself, and event ids keep counting, so a gap is always visible.
Connecting the mobile app to your server
The mobile app normally keeps everything on the phone. It can instead connect to your self-hosted instance — your data then lives there, synced like the browser PWA. Same app, not a different download; offered on first launch and under Settings → Account → Connect to my server.
Passkeys can't work inside the app's WebView (it never runs at your real hostname), so the device is paired instead:
- In a browser that's already signed in: Settings → Account → Pair the mobile app. Confirm it's you (your passkey, or your password where password sign-in is on), and it shows a one-time code, valid 5 minutes.
- In the app: enter your server's address and that code. Done.
Worth knowing: the pairing exchanges the code for a bearer token that is the same signed
value a session cookie carries — so "sign out everywhere" in the browser revokes
paired apps too. Use an https:// address where you can. A plain
http:// server on your home network works too if you type the address with
http:// in front (the app reminds you it is not encrypted). Pairing fails while the browser works?
A reverse proxy or a login page in front of /api/ is answering instead of
MeuPlano Fit; see Phone app and CORS. A connected app keeps working without a network on the
last copy it synced — it shows an "Offline" line under the header and pushes your changes
as soon as the connection is back, same as the browser PWA. Settings → Account → Disconnect syncs one last
time, then drops the device cleanly back to local mode. (For the curious: it's
POST /api/pair/create + /api/pair/redeem in
api/server.js.)
Let an AI coach write your plan
Separate from the read-only bridge above, MeuPlano Fit has an optional AI Coach. Answer a few questions — goal, days a week, session length, equipment, anything to work around — and it designs a week of routines. Later it reviews what you actually logged and proposes changes; you can also ask it to look at one workout (a debrief: a score, what went well, what to watch, what to do next time) or to improve a single routine and leave the rest alone. If the admin allows it and at least three people opt in, it can show anonymous medians so you can see how you sit against the others on your instance.
How it works
The Coach is a chat. The first visit is a short intake, one question per screen; after that, every request works the same way:
- The server builds a JSON payload from a by-name allowlist: your plan, the sets you logged in the review window, weigh-ins, your intake answers, and a few preferences — under a pseudonym. Your name, e-mail, passkeys and everyone else's data are not in it, by construction: fields are copied in one by one, nothing is passed through.
- That payload goes to the provider you configured — there is no MeuPlano Fit service in the middle.
- The answer is parsed and validated against a closed list of change types (add, remove or swap an exercise, sets, reps, schedule, progression policy…). An answer that names an exercise that doesn't exist, invents a change type, or breaks a rule is refused — it gets exactly one repair round, then the job fails rather than guessing.
- What survives is shown to you as a card, one checkbox per change, each with the evidence behind it. Nothing touches your plan until you accept it, every proposal stays in the chat history whether you said yes or no, and the last accepted change-set can be undone in one tap.
Providers
It is off by default. An admin turns it on in Settings → Account → Admin dashboard,
on the AI Coach card, and picks who answers. No admin dashboard in your Settings?
Then nobody is admin yet, and that comes first: make yourself admin.
There's no .env switch for the Coach itself.
| Anthropic, OpenAI, Google Gemini | Paste an API key from the provider's console. Plain HTTPS from the api process — runs on the default image. |
| OpenAI-compatible endpoint | Any server that speaks the Chat Completions API: Ollama, LM Studio, vLLM, OpenRouter, a gateway of your own. A key only if the endpoint wants one. Also the default image. |
| Claude Agent SDK / Codex CLI | Run a real AI runtime inside the container — the one case that needs the bigger build: API_TARGET=coach docker compose up -d --build api |
Run it fully local — no cloud, no cost
A model on your own hardware is just the compatible endpoint with no key. The usual way is an Ollama container next to MeuPlano Fit:
# docker-compose.yml — add next to the MeuPlano Fit services (same file = same network)
ollama:
image: ollama/ollama
restart: unless-stopped
volumes:
- ./data/ollama:/root/.ollama
environment:
- OLLAMA_KEEP_ALIVE=-1 # keep the model (and its prompt cache) loaded
- OLLAMA_NUM_PARALLEL=1
mem_limit: 4g
docker exec -it <ollama container> ollama pull qwen2.5:3b
Then on the AI Coach card (Settings → Account → Admin dashboard): provider OpenAI-compatible endpoint →
Base URL http://ollama:11434 → List models → pick the model →
Test the Coach. That's the whole setup.
Any model you can pull, you can use. ollama pull whatever you like and
it appears under List models — the admin is free to run anything the box can hold,
and each provider remembers its own model choice, so switching around never loses one.
Honest sizing, from measuring exactly this on a small box:
- A ~3B model at Q4 (like
qwen2.5:3b) fits a 4–6 GB container and answers a review in one to a few minutes once its cache is warm — a modern 4-core CPU sits at the fast end, an old low-power box at the slow one. - A 4B model needs ~6 GB+ for its container. Squeezed into less, it doesn't fail — it thrashes, generating at ~1 token/second. Smaller and comfortable beats bigger and swapping.
- The first request after a restart is slow once: the model loads and reads the Coach's rules. The api pre-warms that cache at boot and every half hour, so in practice people hit the warm path.
- On a genuinely slow box, raise the job budget with
COACH_JOB_TIMEOUT_MSin.env.
A GPU machine elsewhere on your LAN works the same way: run Ollama or LM Studio there and
point the Base URL at it, e.g. http://192.168.1.50:11434.
On the phone
Settings → AI Coach offers two ways in: pair the phone with your self-hosted instance and the Coach runs there under the rules above, or bring your own API key and the phone calls the provider directly — same allowlist, same validator, key in the platform's secure storage, ten runs a day.
Privacy and limits
Before anyone uses it, a consent screen lists exactly which categories of data leave the
server — generated from the same module that builds the payloads, so it cannot drift from
what actually happens. The admin can cap runs per person and per instance per day, and
COACH_DISABLED=1 in the environment switches the feature off harder than any
toggle. The Coach is not a doctor or a physiotherapist — if something hurts, ask a
professional. Full details, including exactly what is sent and how credentials are stored:
docs/AI_COACH.md.
Ask an AI about your training (MCP)
MeuPlano Fit ships an optional MCP server: a small read-only bridge that lets a client
like Claude Desktop, Cursor or Cline answer questions from your actual log — "what
did I bench last week?", "which muscle have I not trained this month?". It
runs on your machine, is spawned by the client over stdio, reads the same
./data files the API writes, and its numbers come from the same pure
functions the Stats screen uses — so the answers match the app exactly.
Eight tools in v1:
list_routines | What routines are saved? (names + exercise counts) |
get_routine | What does one routine prescribe, set by set? |
get_week_plan | This week's plan, including any date override. |
list_workouts | Recent sessions — dates, sets done/planned, volume, duration, PRs. |
get_workout | Full set-by-set breakdown of one session, by id or date. |
get_bodyweight | Weigh-ins, the goal line, deltas vs goal. |
estimate_1rm | Best 1RM for an exercise + trend, or a PR table across all. |
muscle_balance | Muscles trained this week/month/all-time, ranked — and the neglected ones. |
It is not part of the Docker build — if you don't use an AI assistant, it simply
isn't there. No new auth (the filesystem is the boundary), no telemetry, no network; the
LLM never sees passkeys or session secrets. Setup is cd mcp && npm
install plus a few lines of client config:
mcp/README.md.
Import your training history
Coming from another tracker? Settings → Data & backup → Import from another app reads a CSV export; these apps work without touching the file:
| FitNotes (Android) | Settings → Backup/Export → Spreadsheet Export |
| FitNotes 2 (iOS) | Export workouts as CSV |
| Strong | Settings → Export Data |
| Hevy | Settings → Export & Import Data (workouts or measurements) — or skip the CSV entirely, below |
| Gravl | Profile → Export Data |
| Apple Health | Body-weight history straight out of the XML export |
Exercise names are matched against the library; anything genuinely ambiguous becomes one of your own custom exercises instead of being guessed at — nothing in the file is dropped. Session lengths and the RPE they record carry over. A summary shows exactly what will happen before anything is written, and importing is idempotent — running it twice never duplicates a workout.
Hevy, directly over the API
With Hevy Pro, skip the CSV: create an API key under Hevy → Settings → Developer, then in MeuPlano Fit use Settings → Data & backup → Import from Hevy, paste the key (used only for that import — never saved), and choose any mix of workouts, routines and weigh-ins. Workout days that already have data here are left alone; imported routines are always added as new plans.
Rolling your own CSV
Anything with date, exercise-name and weight/reps columns imports. Minimal example:
workout name,exercise,date,weight kg,reps
Leg Day,Squat,2026-08-21,120,5
Leg Day,Squat,2026-08-21,125,4
Leg Day,Leg Press,2026-08-21,200,1
Recognised headers (first match wins) for each field: exercise (also
exercise name/title), date (workout date),
start/end time, workout name (title, workout),
category (body part, muscle group), weight
(weight kg, weight lbs, plus a weight unit
column), reps (repetitions), rpe, rir
(reps in reserve), distance (distance km + unit),
seconds (duration seconds, set duration sec),
time (duration), set type, note
(comment(s), notes, workout notes). The full
table lives in
docs/DATA_IMPORTS.md.
Importing plans
Routines travel as their own small JSON file (Plan → share button (top right) → Export plan file exports every current routine; importing merges). Don't want to share everything? Edit the JSON, or use the demo as a free plan editor: import there, prune, export a fresh file. The demo stores everything in your browser — nothing is public or shared.
Backups & your data
Per user
Settings → Data & backup → Export backup (JSON) writes your entire log — plan, workouts, weights, settings — into one file. On the mobile app it goes out through the share sheet (Files, AirDrop, mail…); in the browser it downloads. Import backup restores it anywhere: another phone, your self-hosted instance, or a fresh install. That file is the whole point of MeuPlano Fit — your training log as a file you own.
Per instance (self-hosted)
Everything lives in ./data on your host:
db.json | Profiles and public passkeys. Private keys never touch the server — they stay in your phone's secure hardware or password manager. |
state-<user>.json | One file per user: plan, workouts, body weight, settings. |
audit.log | The activity log (if on) — one JSON object per line. |
secret | The session-cookie signing key. |
vapid.json | Push-notification keys, generated on first run. |
tar czf meuplano-backup-$(date +%F).tar.gz data/
That archive is everything — profiles, passkeys, history. It also contains
audit.log with everyone's sign-in times; worth knowing before shipping it
to a backup service you don't run. Restore by unpacking into the project folder.
How it's built
Two containers, one folder, no database server:
- frontend/ — React 19 + Vite (React Router, Zustand), built to static files inside Docker. The app ships no runtime dependencies beyond React, the router and Zustand.
- api/ — Node with no framework and two dependencies
(
@simplewebauthn/serverfor passkeys,web-pushfor notifications), storing everything as plain JSON files under./data. - web/ — a multi-stage image that builds the frontend and serves it with nginx,
proxying
/apito the backend so it's all on one origin (passkeys require this).
The training logic — progression rules, 1RM estimation, how a logged session is read
back — lives in pure functions under frontend/src/lib/ with tests next to
them; the MCP server and the mobile app import the very same functions. Every pull
request runs the full suites (frontend, api, mcp) on GitHub Actions and builds and boots the
api images. A published release builds the amd64+arm64 images; the APKs and the iPhone app
are signed on the release machine, because the sharper animations inside them may never
sit in a CI run (licence). The GitLab mirror's CI builds images and SBOMs for its own
registry.
MeuPlano Fit is developed with Claude Code, Anthropic's coding agent: a large share of the code, tests and documentation is drafted in Claude Code sessions. What goes in, every review and every release is the maintainer's call, and each release is tested on a staging instance and on real phones first. The app itself needs no AI service — the AI coach and the MCP server are opt-in. More on this in the README.
The exercise catalogue lives in the repository as one JSON file per exercise (catalogue/), with its translations next to it, and anyone can fix or translate an entry with a pull request. The pictures and animations are licensed from Gym visual for MeuPlano Fit and ship with it at 180 px (see the FAQ for the licensing details).
Build it yourself
Everything on this page is built from the public repo:
- Web / self-host:
docker compose up -d --buildbuilds the frontend inside the container — no local Node needed. - Android APK:
npm run build:mobileinfrontend/, then Gradle +zipalign+apksignerwith your own keystore (keep it — updates must carry the same signature). Step-by-step: docs/MOBILE.md. - iOS app: the Capacitor project in
frontend/ios: open it in Xcode, set your (free) team, run it on your device. Same doc; how the App Store build is made is in docs/APP_STORES.md. - Development: Node 20+;
npm testinfrontend/,api/andmcp/runs the suites. Contributions welcome — CONTRIBUTING.md.
Troubleshooting
Passkeys fail even though the config looks right
The most common support question — work through these in order:
- Ask the server what it actually loaded:
docker compose logs api | grep 'gym-api on'prints therpID/originit runs with. If that disagrees with your.env:docker compose restartdoes not re-read.env— usedocker compose up -d. - Check the exact shape:
RP_IDis a bare hostname (gym.example.com— nohttps://, no port, no slash);ORIGINis the full origin with scheme and without trailing slash. Both must match the address bar exactly. - Behind a tunnel/proxy, use the public hostname — never the container name, LAN IP or internal port. The tunnel's own route may point wherever it likes.
www.is a different host. A passkey registered ongym.example.comwon't work onwww.gym.example.com— pick one, redirect the other.- Changing the hostname invalidates existing passkeys. Everyone registers again.
Everything else
| No passkey prompt on my phone | You're on http:// or a bare IP. Passkeys need HTTPS or localhost — set up a domain, or use guest mode / the mobile app. |
| "This server only takes changes made at ..." | ORIGIN isn't the address in your browser. docker compose logs api shows a refused cross-origin line naming the ORIGIN= and RP_ID= to set; then docker compose up -d. |
| Sign-in never works, only "Continue without account" | The api container isn't running or web can't reach it (BACKEND, PORT). As a guest nothing is saved on the server. |
| "verification failed" on login | RP_ID/ORIGIN don't match the address bar — see the checklist above, starting with what the server logged. |
| Exercise pictures missing | You're on an image older than v1.4.0 or a custom build: docker compose pull && docker compose up -d. A source build needs catalogue/media/ checked out. |
| Port 8080 already used | WEB_PORT=9090 in .env (and update ORIGIN for local testing). |
| No "Reminders" page in Settings | Needs a signed-in profile and HTTPS (or localhost) — guest mode and plain HTTP over LAN can't subscribe. |
| Day reminder fires at the wrong time | Toggle it off and on so it re-detects your browser's timezone (also happens on every app load). |
| "Keep screen awake" shows as unsupported | The Wake Lock API needs HTTPS or localhost; iOS also refuses it in Low Power Mode. |
| Want to reset a stuck login | Delete the cookie — sessions are just signed cookies. "Sign out everywhere" in Settings → Account ends every session (and paired apps) at once. |
docker compose pull says "denied" | Build from source instead: docker compose up -d --build. |
| Exercise images blank in an old build | Fixed in current images. VITE_IMG_BASE/VITE_GIF_BASE are build-time: they're baked into the bundle when the frontend compiles, so setting them at runtime does nothing to a prebuilt image — redirect media in your reverse proxy instead, or rebuild. |
Still stuck? The Discord is the quickest way to get an answer; Discussions are better for anything the next person should find by searching, and issues for bugs.
Privacy — the complete list
- The app collects nothing. No telemetry, no analytics, no crash reporting, no accounts on anyone else's server. The exercise media are inside the mobile app. It goes online only for what you set up (your server, the coach, Health Connect, the gym reminder's map and address search, which ask OpenStreetMap) and, in the APK from this site, for GitHub's public release list when you open Settings → About (the update check). The full policy: Privacy. In the browser, a self-hosted instance talks only to your own server.
- Passkey private keys never leave your device — the server stores only public keys.
- The optional activity log (self-hosted, admin feature) records sign-in events but no IP addresses, user-agents or failed-attempt passkey ids unless the operator opts in — details above.
- The AI Coach is opt-in twice over — an admin has to enable it, and a consent screen lists exactly which data categories leave the server before anyone uses it. Off, nothing is ever sent anywhere — details above.
- The MCP server is read-only, local, and never puts your data on the network.
- This website uses self-hosted, cookieless Umami page-view counting — no cookies, no cross-site tracking, and it never sees anything from inside the app or the demo.
FAQ
Is it really free?
Yes — free as in cost and as in freedom (AGPL-3.0). No pro tier, no locked features, no ads, and the license keeps every fork open source too. If it replaced a paid tracker for you, there's a coffee button, a Ko-fi page and GitHub Sponsors (the app has all three under Settings → About & updates), though a star, a bug report or a pull request is worth just as much.
Is it in the App Store and on Google Play?
The App Store: yes, since 23 October 2026, for iPhone and Apple Watch (link). Google Play comes once a new developer account has run its closed test. On Android the signed APK is the way in until then, and the same key signs both, so moving to Play later keeps your data. The store apps are the same AGPL code, with nothing collected and nothing to buy.
How do I use it on my iPhone?
The easy way is the App Store app,
on its own or connected to your server. Or as a web app: open your MeuPlano Fit address (it has to be
https://) in Safari, tap Share → Add to Home Screen, open the new icon
and create your profile; Face ID makes the passkey. It runs full screen, works offline and can
send notifications from there. Just curious? The demo works in Safari too.
Passkeys don't work on plain http, or my laptop has no fingerprint reader.
Browsers only do passkeys over HTTPS (and on http://localhost), so
http://ip:port never gets a passkey prompt, whatever you set. Put HTTPS in
front (see above, Tailscale Serve included, or a LAN certificate via
SELF_HOSTING_HTTPS.md).
A fingerprint reader is not required: any passkey works, from your phone via the browser's
QR code, a password manager, a security key or a Windows Hello PIN. Or switch on passwords
with PASSWORD_LOGIN=1 in .env and docker compose up -d:
the sign-in screen then offers Sign in with password and new profiles can use one.
Can I use a password instead of a passkey?
Yes, if the instance allows it: PASSWORD_LOGIN=1 in .env, then
docker compose up -d. Off by default. The sign-in screen gets Sign in with
password (profile name or sign-in e-mail), new profiles can use one, and a passkey profile
adds one under Settings → Account → Password. MeuPlano Fit never sends mail, so a forgotten
password is reset with a one-time code from the admin dashboard. Switching it off later locks
out profiles that only have a password. More on signing in.
What is guest mode, and can I turn it off?
Continue without account keeps everything in that browser: no profile, no sync,
nothing on the server. Make a profile on the same device later and the data moves in. For an
instance meant for a known set of people, ALLOW_GUEST=0 removes the button;
INVITE_ONLY=1 decides who may create a profile. Stuck as a guest because sign-in
never works? The api container isn't reachable (NAS notes).
The AI Coach doesn't show up on my server.
Nothing in .env switches it on; an admin does, in the app. If Settings →
Account has no Admin dashboard, nobody is admin yet: copy Settings → Account →
Account ID into ADMIN_UIDS, docker compose up -d, reload.
Then Admin dashboard → AI Coach → Set up the Coach, connect a provider, test it, and
the Coach appears in the menu at the top right of Plan.
More on providers.
How do I serve it under /gym with nginx?
Either nginx passes /gym/ through and .env has
BASE_PATH=/gym, or nginx strips it and BASE_PATH stays empty;
never both. ORIGIN has no path, and leave out proxy_cookie_path.
A working config is under Fitting it into an existing stack.
Can I move my data from the phone app to my server?
Yes. Easiest: pair the app with the server (how) and answer Add them when it asks about this device's workouts. Or export a backup in the app (Settings → Data & backup → Export backup) and import it on the server, signed in, under Settings → Data & backup → Import backup; it asks before replacing anything the server has that the file doesn't.
Mobile app or self-hosted — which one should I pick?
If you just want to track your training: the mobile app. If you want your data on your own hardware, synced across devices, with profiles for the people you train with: self-host. You can start with the app and move to a server later — the backup file migrates everything, or pair the app to your server and keep using it.
What's on the roadmap?
A release roughly every one to two weeks. v1.4.3 (the gym reminder, heart rate and weigh-ins read back from Apple Health and Health Connect, a smarter Apple Watch) is in App Review. v1.4.4 brings programmes with %1RM and RPE targets, your own app icon and kg or lb per exercise; v1.4.5 remote MCP over HTTPS and Google Play. After that, in rough order and with dates that are honest guesses: circuits and EMOM, a trainer role, OIDC sign-in, F-Droid, a free Coach that runs on the phone, Strava and Garmin, and database storage. The full plan is in ROADMAP.md; ideas and pull requests welcome.
Was MeuPlano Fit written with AI?
It's developed with Claude Code, and a large share of the code is drafted that way — reviewed, tested and released by the maintainer. The app itself calls no AI service unless you switch the coach on. See How it's built.
Where does the exercise database come from?
The text is MeuPlano Fit's own: one JSON file per exercise in catalogue/, translations next to it, and fixes welcome as pull requests. The 1,324 exercises MeuPlano Fit started with came from ExerciseDB by way of hasaneyldrm/exercises-dataset, and their original text stays under MIT; everything written since is AGPL like the rest of MeuPlano Fit.
The pictures and animations are a different matter: they are © Aliaksandr Makatserchyk, Gym visual, licensed for use in MeuPlano Fit only, at 180 px in the repository and on servers and larger only inside the app packages. They are not covered by the AGPL, and there's no media pack or CDN to pull them from. Reusing them anywhere else needs your own licence from gymvisual.com. Full third-party notices, including the body-diagram geometry, are in NOTICE.md.
What does the AGPL mean for me?
You can self-host, use, modify and share MeuPlano Fit freely. If you run a modified version as a network service, you must offer that version's source under the same license — that's the clause that keeps MeuPlano Fit from ever becoming someone's closed, proprietary product. Just using or self-hosting it unmodified obliges you to nothing.