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.

Android iPhone Self-hosting Ask an AI AI Coach Import your history Backups Build it yourself FAQ

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 appThe 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-hostedTwo 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.
DemoThe 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.

  1. Open the download on your phone and tap Standard (or Plus or HD).
  2. 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.
  3. Open the downloaded MeuPlano Fit-<version>.apk, confirm, done. No account, no setup.
A Google Play version is on its way. Until then the APK is the way in: it is signed, updates install over the old version and keep your data.

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, 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

Routines & the plan builder

Progression — weights that follow a rule

Running a workout

Statistics

Looks, languages & little things

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:

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

Changing 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_IDHostname passkeys are bound to. A bare hostname: no scheme, no port, no slash. Default localhost.
ORIGINFull URL the app is served from — with scheme, without trailing slash. Must match the address bar exactly. Default http://localhost:8080.
RP_NAMEName shown in the passkey prompt. Default MeuPlano Fit.
WEB_PORTHost port for the web UI. Default 8080.
NGINX_PORTPort the web container listens on, inside the container. Default 80.
BACKENDName of the API service /api is proxied to — change it if yours isn't called api.
PORTPort the API listens on; the web container proxies to the same value. Default 3000.
SESSION_DAYSHow long a sign-in lasts, in days. Default 90.
ADMIN_UIDSComma-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_ADMINSet 1 and the first profile created on an instance with no profiles becomes its admin. Has no effect once profiles exist. Default: off.
INVITE_ONLYSet 1 and new profiles need an invite code you generate from the dashboard. Existing accounts keep working. Default: off.
ALLOW_GUESTSet 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_LOGRecord sign-ins and admin actions to the activity log — set 0 to record nothing. Default: on.
AUDIT_MAXEvents kept in the activity log; 0 for no limit. Default 5000.
AUDIT_DAYSDays kept in the activity log; 0 to keep until AUDIT_MAX. Default 90.
AUDIT_IPRecord the caller's address: off, net (network only, e.g. 203.0.113.0/24) or full. Default off.
PASSWORD_LOGINSet 1 to offer name-and-password sign-in next to passkeys. Default: off.
TRUST_PROXYLet 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_LANGLanguage of the sign-in screen and of new profiles, e.g. de or pt-BR. Default: the visitor's browser language.
BASE_PATHServe 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_UPLOADSSet 0 to turn off photo and video uploads (custom-exercise pictures, workout photos); the link field stays. Default: on.
MEDIA_QUOTA_MBUpload space per profile in MB; 0 for no cap. Default 200. Lower it on an instance with open signup.
MEDIA_MIN_FREE_MBUploads 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_TARGETWhich API image to build: default, or coach to add the Claude Agent SDK and Codex CLI runtimes. API-key providers work on default.
VAPID_SUBJECTContact URL sent with push notifications. Default: your ORIGIN.
COACH_DISABLEDSet 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:

  1. 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.
  2. 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:

  1. 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.
  2. That payload goes to the provider you configured — there is no MeuPlano Fit service in the middle.
  3. 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.
  4. 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 GeminiPaste an API key from the provider's console. Plain HTTPS from the api process — runs on the default image.
OpenAI-compatible endpointAny 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 CLIRun 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 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_routinesWhat routines are saved? (names + exercise counts)
get_routineWhat does one routine prescribe, set by set?
get_week_planThis week's plan, including any date override.
list_workoutsRecent sessions — dates, sets done/planned, volume, duration, PRs.
get_workoutFull set-by-set breakdown of one session, by id or date.
get_bodyweightWeigh-ins, the goal line, deltas vs goal.
estimate_1rmBest 1RM for an exercise + trend, or a PR table across all.
muscle_balanceMuscles 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
StrongSettings → Export Data
HevySettings → Export & Import Data (workouts or measurements) — or skip the CSV entirely, below
GravlProfile → Export Data
Apple HealthBody-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.jsonProfiles and public passkeys. Private keys never touch the server — they stay in your phone's secure hardware or password manager.
state-<user>.jsonOne file per user: plan, workouts, body weight, settings.
audit.logThe activity log (if on) — one JSON object per line.
secretThe session-cookie signing key.
vapid.jsonPush-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:

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:

Troubleshooting

Passkeys fail even though the config looks right

The most common support question — work through these in order:

  1. Ask the server what it actually loaded: docker compose logs api | grep 'gym-api on' prints the rpID/origin it runs with. If that disagrees with your .env: docker compose restart does not re-read .env — use docker compose up -d.
  2. Check the exact shape: RP_ID is a bare hostname (gym.example.com — no https://, no port, no slash); ORIGIN is the full origin with scheme and without trailing slash. Both must match the address bar exactly.
  3. 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.
  4. www. is a different host. A passkey registered on gym.example.com won't work on www.gym.example.com — pick one, redirect the other.
  5. Changing the hostname invalidates existing passkeys. Everyone registers again.

Everything else

No passkey prompt on my phoneYou'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 loginRP_ID/ORIGIN don't match the address bar — see the checklist above, starting with what the server logged.
Exercise pictures missingYou'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 usedWEB_PORT=9090 in .env (and update ORIGIN for local testing).
No "Reminders" page in SettingsNeeds a signed-in profile and HTTPS (or localhost) — guest mode and plain HTTP over LAN can't subscribe.
Day reminder fires at the wrong timeToggle it off and on so it re-detects your browser's timezone (also happens on every app load).
"Keep screen awake" shows as unsupportedThe Wake Lock API needs HTTPS or localhost; iOS also refuses it in Low Power Mode.
Want to reset a stuck loginDelete 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 buildFixed 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

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.