- Kotlin 33.9%
- Python 33.6%
- JavaScript 25.8%
- CSS 6.4%
- Dockerfile 0.2%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
|
All checks were successful
Build and Deploy / test-backend (push) Successful in 2m14s
Build and Deploy / build-frontend (push) Successful in 19s
Build and Deploy / build-borg-agent (push) Successful in 46s
Build and Deploy / build-backend (push) Successful in 1m27s
Build and Deploy / deploy (push) Successful in 17s
Build Android APK / build-apk (push) Successful in 4m24s
Reviewed-on: #9 |
||
| .claude | ||
| .forgejo/workflows | ||
| .idea | ||
| android | ||
| backend | ||
| borg | ||
| frontend | ||
| .env | ||
| .gitignore | ||
| docker-compose.yml | ||
| README.md | ||
MedSync — Medication Tracker
A self-hosted medication tracker PWA with bottle scanning via Claude AI, push notifications, and optional SMS / ntfy alerts.
Stack
| Layer | Tech |
|---|---|
| Backend | Python 3.12 · FastAPI · APScheduler |
| Database | SQLite (via Python stdlib) |
| Frontend | React 18 · Vite · PWA (service worker) |
| Serving | nginx (frontend) · uvicorn (backend) |
| Infra | Docker Compose |
Quick Start
1. Generate secrets
# JWT secret
openssl rand -hex 32
# VAPID keys for push notifications
pip install pywebpush
python -c "from py_vapid import Vapid; v = Vapid(); v.generate_keys(); print('Public:', v.public_key); print('Private:', v.private_key)"
# OR use the web tool: https://vapidkeys.com
2. Configure environment
cp .env.example .env
# Edit .env and fill in all values
3. Build and run
docker compose up -d --build
Frontend: http://localhost:3000
API docs: http://localhost:3001/docs (FastAPI auto-generated Swagger UI)
Environment Variables
| Variable | Required | Description |
|---|---|---|
JWT_SECRET |
✅ | Random secret for signing tokens |
ANTHROPIC_API_KEY |
✅ | For pill bottle scanning |
VAPID_PUBLIC_KEY |
✅ | For browser push notifications |
VAPID_PRIVATE_KEY |
✅ | For browser push notifications |
VAPID_EMAIL |
✅ | mailto:you@domain.com |
TWILIO_ACCOUNT_SID |
➖ | Optional — enables SMS alerts |
TWILIO_AUTH_TOKEN |
➖ | Optional — enables SMS alerts |
TWILIO_FROM_NUMBER |
➖ | Optional — your Twilio phone number |
NTFY_SERVER_URL |
➖ | Initial ntfy server (admin-editable later; default https://ntfy.sh) |
NTFY_TOKEN |
➖ | Optional — bearer token for a private server |
NTFY_USERNAME |
➖ | Optional — username for a private server (HTTP Basic) |
NTFY_PASSWORD |
➖ | Optional — password for a private server (HTTP Basic) |
NTFY_PRIORITY |
➖ | Optional — message priority (default default) |
OIDC_ISSUER |
➖ | Optional — OIDC issuer URL (enables SSO) |
OIDC_CLIENT_ID |
➖ | OIDC client id |
OIDC_CLIENT_SECRET |
➖ | OIDC client secret |
OIDC_REDIRECT_URI |
➖ | https://<host>/api/auth/oidc/callback |
OIDC_PROVIDER_NAME |
➖ | Button label (default Authentik) |
FRONTEND_URL |
➖ | Only if frontend ≠ API origin |
OIDC Single Sign-On (Authentik)
MedSync can tie accounts to an OIDC provider such as Authentik. When configured, the login page shows a Sign in with Authentik button and existing users can connect their identity from Settings → Connected accounts.
- In Authentik, create an OAuth2/OpenID confidential provider:
- Redirect URI:
https://<your-host>/api/auth/oidc/callback - Scopes:
openid profile email
- Redirect URI:
- Copy the client id/secret and the provider's issuer URL into
.env(OIDC_ISSUER,OIDC_CLIENT_ID,OIDC_CLIENT_SECRET,OIDC_REDIRECT_URI). - Restart the backend. The feature auto-enables once these are set.
Account linking behaviour:
- A first OIDC login auto-links to an existing account when the
emailclaim (or, failing that,preferred_username) matches a MedSync user. - Otherwise a new account is provisioned from the OIDC claims (no local password).
- Logged-in users can explicitly connect/disconnect from Settings.
Username/password login keeps working alongside SSO.
Admin & User Management
The first account created (whether by registration or OIDC sign-in) automatically becomes an admin. On an existing database with no admin, the earliest user is promoted on next startup.
Admins get an Admin tab, organised into sub-sections down the left-hand side. Each one is its own URL, so a link to a particular screen can be bookmarked and the back button works normally:
| Sub-section | /admin/… |
What lives there |
|---|---|---|
| General | general |
Display timezone, Anthropic API key |
| Login | login |
OIDC / single sign-on |
| Users | users |
Create accounts, promote/demote, reset passwords, delete |
| Notifications | notifications |
ntfy server, web push (VAPID), Twilio SMS |
| Backup | backup |
Whole-instance archives to Nextcloud, and restore |
| Borg backup | borg |
Deduplicated backups to a remote Borg server |
The app refuses to demote or delete the last remaining admin, and you cannot delete your own account.
Every setting on these screens is stored in the database, not the environment. The .env
values only seed the initial value of a setting that has never been edited — once an
admin saves one, the database wins and the env var is ignored.
Backup & Restore
Two independent levels, both reachable from the UI and both able to write either to your browser's Downloads folder or to a Nextcloud server over WebDAV — plus a third, admin-only destination: a remote Borg repository over SSH.
Full system backup (Admin tab)
A single .zip holding a consistent snapshot of the SQLite database plus every
uploaded pill photo — all accounts, medications, dose history and stored settings.
- Download archive — streams the archive straight to the browser.
- Back up to Nextcloud — uploads it to the configured destination.
- Automatic backups — an optional nightly run at a chosen hour (in the instance's display timezone), keeping the newest N archives and deleting the rest.
- Restore — from an archive listed on Nextcloud, or from a file you upload.
The database snapshot is taken with SQLite's online-backup API, not a file copy, so it is consistent even though the app runs in WAL mode and is serving requests at the time.
⚠️ A full archive contains password hashes, API-key hashes and every stored integration credential (Anthropic key, VAPID private key, Twilio token, OIDC secret). Treat it as sensitive.
⚠️ Restoring replaces everything — every account and dose logged since that backup is lost. The database being replaced is kept on the server next to the live one as
medsync.db.pre-restore-<timestamp>(the newest three are retained), so a mistaken restore is still recoverable from the filesystem.
Borg backup (Admin → Borg backup)
Deduplicated, compressed, encrypted, incremental backups pushed to a remote Borg repository over SSH. Unlike the Nextcloud archive — a fresh full zip every night — only the chunks that actually changed cross the wire, so months of nightly history costs little more than a single archive.
This one runs in its own container, borg-agent, because it needs three things the
backend should not have: the borg binary, an SSH private key, and the ability to stop
MedSync itself.
browser ──▶ backend ──▶ borg-agent ──▶ ssh ──▶ your Borg server
│
├── stops/starts medsync-backend (Docker socket)
├── holds the SSH key (medsync-borg volume)
└── reads the database + photos (medsync-data volume)
Setting it up
- Generate an SSH key under SSH identity. The private half is created inside the
agent container and never leaves it; copy the public key shown and add it to
~/.ssh/authorized_keyson your Borg server (or to your provider's key list — BorgBase, rsync.net and Hetzner Storage Box all work). - Set the repository, e.g.
ssh://borg@backup.example.com:22/./medsync. The scp-styleborg@host:medsyncalso works, as does an absolute path if you mount a disk into the agent container. - Generate a passphrase — or type your own — and write it down somewhere else. It is stored here so nightly backups can run unattended, but the archives cannot be read without it, and losing both the instance and the passphrase means losing the backups.
- Save settings, then Create repository (
borg init --encryption repokey-blake2). This only works on an empty path; point at an existing repository and skip this step. - Test connection to confirm SSH, the key and the passphrase all line up.
Host keys are trusted on first use by default and pinned thereafter. Scan host key pins one explicitly, which lets you switch Host key checking to strict.
Cold vs hot
Stop MedSync while the backup runs is on by default. The agent stops the listed containers, takes the archive, then starts them again in reverse order — nothing can be mid-write while the data is read, at the cost of the app being unavailable for as long as the archive takes (seconds, after the first run has seeded the repository).
Turn it off and the app stays up: the database is still captured consistently through SQLite's online-backup API, but a pill photo uploaded during the run may miss the archive.
A restore always stops MedSync, whatever this setting says — swapping the database out from under a running backend is not survivable.
Because a cold backup stops the very process the browser is talking to, the panel works a little differently from the rest of the app: starting a backup returns a job, the job id is remembered across a page reload, and a failed poll is treated as "still running" rather than an error. The outcome is written into the run log by the agent itself, so it survives the backend being restarted underneath it.
⚠️ The agent mounts
/var/run/docker.sock, which is effectively root on the host. That is what lets it stop and start containers. If you'd rather not grant that, drop the socket mount fromdocker-compose.ymland turn the cold-backup toggle off — backups keep working, restores do not.
Retention
After each successful backup the agent applies borg prune with the keep daily / weekly /
monthly counts from the panel, then borg compact to actually release the space. Setting
one of the three to 0 keeps none of that kind. Pruning failures are reported but never
fail a backup that has already been written.
Personal backup (Settings tab)
Each user can back up and restore their own data — every medication they've added with its ingredients and pill photo, their groups, and the complete record of what they took and when.
| Format | Contents | Restorable |
|---|---|---|
.zip |
Everything, including pill photos | ✅ |
.json |
The same document without photos | ✅ |
.csv |
Dose history only, for spreadsheets | ➖ |
Restoring offers two modes:
- Replace — clear the account's medications and history, then rebuild from the backup.
- Merge — keep what's there and add only what's missing. Medications are matched by name and doses by medication + timestamp, so merging the same bundle twice is a no-op.
A user bundle carries no credentials and restores into the account doing the restoring, never as an account — so it can't be used to take over a login, and restoring someone else's exported file simply copies that data into your own account.
Caretakers can back up and restore for a patient they manage, using the same
for_user_id access check as the rest of the app.
Configuring Nextcloud
Both levels use Nextcloud's WebDAV endpoint (/remote.php/dav/files/<user>/) with HTTP
Basic auth. Create an app password under Settings → Security in Nextcloud rather
than using your account password, so it can be revoked independently.
The instance-wide destination is set by an admin and is stored in the database (an admin-editable setting, like the ntfy server). Each user's personal destination lives on their own account and is entirely separate — a user's backups never touch the admin's credentials or folder, and vice versa. Both offer a Test connection button that verifies the credentials and creates the target folder.
Public API (for other apps)
MedSync exposes a small, scoped API so other apps and scripts can view your medications and log doses without your login credentials.
📖 Full reference: https://your-host/api/public/docs — a self-contained page
covering every key-accessible endpoint, its parameters, response fields, and
worked curl examples. It needs no login, so you can hand the URL to whoever is
writing the integration. The summary below is just an orientation.
API keys
Generate a key by calling the management endpoints with a normal logged-in session (JWT). Keys are shown once at creation — store them securely.
# Create a key (returns the plaintext "key" exactly once)
curl -X POST https://your-host/api/auth/api-keys \
-H "Authorization: Bearer <your-jwt>" \
-H "Content-Type: application/json" \
-d '{"name": "Home Assistant"}'
# → {"id": 1, "name": "Home Assistant", "key_prefix": "msk_AbC12345", "key": "msk_…"}
curl https://your-host/api/auth/api-keys -H "Authorization: Bearer <your-jwt>" # list
curl -X DELETE https://your-host/api/auth/api-keys/1 -H "Authorization: Bearer <your-jwt>" # revoke
Only the SHA-256 hash of a key is stored; a lost key can only be replaced, not recovered. Managing keys requires a real login — an API key cannot mint or revoke keys.
Using a key
Pass the key as a bearer token, exactly like a JWT:
Authorization: Bearer msk_…
Scope — an API key can only view and log. It cannot delete medications,
edit your profile, manage caretakers, or reach admin settings; those stay
login-only. A key inherits the owner's caretaker access, so for_user_id works
for patients they care for. Use GET /api/caretaker/patients to discover those
patient ids — it lists who the key's owner cares for, but linking and unlinking
patients remains login-only.
| Capability | Endpoint |
|---|---|
List the patients you're a caretaker for (id, username, display_name) |
GET /api/caretaker/patients |
List medications & supplements (with next_due / overdue) |
GET /api/medications |
| List groups and their members | GET /api/groups |
Dose history (rolling window, or start/end for a past range) |
GET /api/medications/history?days=7 |
Log a dose (now, or backdated via taken_at) |
POST /api/medications/{id}/log |
| Log every med in a group at once | POST /api/groups/{id}/log |
Supplements are simply medications with category: "supplement", so they appear
in the same lists. When a medication is next due is returned as the
next_due (ISO-8601) and overdue (boolean) fields on each medication;
next_due is null for paused meds, as-needed (PRN) meds, and ones never taken.
overdue means "MedSync would chase you for this", not merely "past its time":
it is false for an as_needed med (no schedule at all) and for one with
reminders_enabled: 0 (muted on purpose, so it still reports a past next_due
without ever alerting). Compare next_due to the clock yourself if you want raw
lateness rather than MedSync's judgement.
# View what's due
curl https://your-host/api/medications -H "Authorization: Bearer msk_…"
# Log a dose taken two hours ago
curl -X POST https://your-host/api/medications/42/log \
-H "Authorization: Bearer msk_…" -H "Content-Type: application/json" \
-d '{"taken_at": "2026-07-14T10:00:00+00:00"}'
Interactive docs for all endpoints are at /docs (Swagger UI).
Android
Two options — they talk to the same API and can be used interchangeably.
Native app (recommended on a phone)
A Kotlin/Compose client lives in android/. It mirrors the
web UI and keeps working without a connection: cached medications, groups and
history stay readable, and doses, edits, pauses and group logs queue locally and
replay in order once the server is reachable again — including after the app is
closed.
cd android
./gradlew assembleRelease
adb install -r app/build/outputs/apk/release/app-release.apk
CI builds it too: .forgejo/workflows/build-android.yaml uploads the APK as an
artifact on every push under android/.
It also raises dose reminders on the phone itself, scheduled from the cached schedule rather than pushed from the server — so they still fire when the server is unreachable, and each one can log the dose straight from the notification. SMS and ntfy remain available alongside them.
On first launch enter the same address you use in a browser. Admin and OIDC sign-in are web-only — see the Android README for why, and for what queues offline.
PWA
- Open Chrome on Android and navigate to your server URL
- Tap the ⋮ menu → "Add to Home screen"
- The app installs like a native app with push notification support
- First launch: tap Enable Notifications in the app settings
Project Structure
medsync/
├── docker-compose.yml
├── .env.example
├── backend/
│ ├── Dockerfile
│ ├── requirements.txt
│ └── app/
│ ├── main.py # FastAPI app + lifespan
│ ├── db/
│ │ └── schema.py # SQLite init + context manager
│ ├── routes/
│ │ ├── auth.py # Register, login, profile
│ │ ├── admin.py # Admin user management
│ │ ├── medications.py # CRUD + dose logging
│ │ ├── public_docs.py # Public API docs page (/api/public/docs)
│ │ ├── backup.py # System + per-user backup / restore
│ │ ├── borg.py # Borg settings + agent endpoints (admin)
│ │ └── scan.py # Claude Vision bottle scan
│ └── services/
│ ├── auth.py # JWT + bcrypt
│ ├── backup.py # Archive building, restore, user bundles
│ ├── backup_jobs.py # Destinations, retention, run log
│ ├── borg.py # Thin client for the borg-agent container
│ ├── nextcloud.py # WebDAV client (MKCOL/PUT/PROPFIND/GET)
│ ├── notifications.py # WebPush + Twilio SMS + ntfy
│ └── scheduler.py # APScheduler — dose checks + nightly backups
├── borg/ # Sidecar: borg binary, SSH key, container control
│ ├── Dockerfile
│ └── agent.py # HTTP agent the backend drives
├── frontend/
│ ├── Dockerfile
│ ├── nginx.conf
│ ├── vite.config.js # Vite + PWA plugin
│ └── src/
│ ├── services/api.js # All API calls
│ ├── pages/AdminPage.jsx # Admin shell — sidebar + sub-section outlet
│ ├── pages/admin/ # One component per admin sub-section
│ ├── contexts/AuthContext.jsx
│ └── hooks/usePushNotifications.js
└── android/ # Native Kotlin/Compose client
└── app/src/main/java/com/medsync/android/
├── data/ # API client, offline cache, outbox, sync
└── ui/ # Theme ported from index.css, screens
Development (without Docker)
Backend:
cd backend
pip install -r requirements.txt
cp ../.env.example .env # fill in values
uvicorn app.main:app --reload --port 3001
# Swagger UI at http://localhost:3001/docs
Frontend:
cd frontend
npm install
npm run dev # proxies /api to localhost:3001
Android:
cd android
./gradlew installDebug # needs JDK 17 + an Android SDK (see android/README.md)
From an emulator, the host's backend is 10.0.2.2:3001.
Reverse Proxy (recommended for production)
Put nginx or Caddy in front to add HTTPS — required for:
- Camera access on Android Chrome
- Push notifications (requires secure context)
Example Caddy snippet:
medsync.yourdomain.com {
reverse_proxy localhost:3000
}