A self-hosted family medical information manager: medications, doctors, and appointments, with a big-button tablet "Today" view designed for family members who aren't especially tech-savvy. Runs on your own hardware — a Raspberry Pi is the reference target, but a Windows PC works just as well if you don't have a Pi (see Windows Server below) — your data stays on your network.
- Backend: Node.js/Express + SQLite (
better-sqlite3), the single source of truth. - Tablet PWA: a Vite/React progressive web app served by the same backend at
/— one service, one install, no separate deploy. - Admin app (
admin/): a Windows desktop app (Electron) for managing profiles, medications, doctors, appointments, and actions (exercise, self-directed physio, etc), uploading scans/photos of medical documents to a person's file, exporting or importing a single person's whole profile, and exporting/restoring a full backup of everything, plus a compliance history view — see admin/README.md. - No accounts, no cloud, no telemetry. It's meant to run on a private network (see the warning below) and be administered by whoever installs it.
![]() First run: pick who this tablet is for |
![]() Today: meds grouped by time of day, tap to mark taken |
![]() "What is this?" opens a plain-language description |
![]() Upcoming appointments — confirm with one tap |
MedFam has no authentication. Anyone who can reach the server's port can read and write everyone's medical data. This is a deliberate design choice for a small, trusted-network, single-family tool — do not port-forward it or otherwise expose it to the public internet. Put it behind a VPN (e.g. Tailscale) or keep it strictly on your home LAN.
On a Debian-based Linux box (Raspberry Pi OS, Ubuntu, Debian):
curl -fsSL https://raw.githubusercontent.com/ShinobiFPV/MedFam/master/install.sh | bashThis installs Node.js if it's missing, downloads MedFam, builds the tablet PWA, installs dependencies, and sets up a systemd service that starts on boot. It'll prompt you to confirm a few things along the way (or pass flags to skip the prompts):
| Flag | Default | Meaning |
|---|---|---|
--dir=PATH |
$HOME/medfam |
Where to install |
--user=NAME |
the user running the installer | System user the service runs as |
--port=N |
8093 |
Port to listen on |
--timezone=Area/City |
auto-detected from the OS | IANA timezone for "what's due today" |
--update |
— | Update an existing install in place |
When it finishes, it prints the URL to open and a reminder about the warning above.
Don't have a Pi? MedFam runs the same way on a Windows PC — the backend is plain Node.js/Express, so nothing about it is Pi-specific. The PC just needs to stay on and reachable on your LAN, the same as a Pi would.
git clone https://github.com/ShinobiFPV/MedFam.git
cd MedFamThen, from an elevated PowerShell (right-click PowerShell → "Run as Administrator") in that folder:
.\install.ps1This checks for Node.js 18+ (installing it via winget if missing), installs
dependencies, builds the tablet PWA, prompts for a port and timezone, registers MedFam
as a real Windows Service (visible in services.msc, starts at boot even before
anyone logs in, auto-restarts on crash — the Windows analog of the Pi's systemd unit),
and opens the port in Windows Firewall.
| Flag | Default | Meaning |
|---|---|---|
-Port N |
8093 |
Port to listen on |
-Timezone Area/City |
auto-detected from the OS | IANA timezone for "what's due today" |
-Update |
— | git pull, rebuild, and restart the service in place |
-Uninstall |
— | Stop and remove the service (your data in data\medfam.db is untouched) |
-SkipPwaBuild |
— | Skip the PWA rebuild (combine with -Update for backend-only changes) |
Manage the service afterwards with the usual PowerShell cmdlets: Get-Service MedFam,
Restart-Service MedFam, Stop-Service MedFam.
On the tablet, open the LAN URL the installer prints (e.g. http://192.168.1.50:8093)
in the browser — same PWA, same API, nothing PWA-side needs to know or care whether the
backend is a Pi or a Windows PC. Point the Admin app's Settings →
Server Address at that same URL.
The same warnings as the Linux install apply: no login, LAN/VPN only, and the PWA's offline/install features need HTTPS (see Tablet PWA below) — none of that changes just because the server is a PC instead of a Pi.
Roadmap: today the tablet needs the Windows PC or Pi reachable to load anything. Making the PWA usable fully offline — caching all data locally and syncing with whichever server (Windows or Pi) it can next reach — is planned, but is being sequenced after a TWA (or native) tablet app, since that's what makes an offline-capable install actually installable on the tablet in the first place.
Set via environment variables (the installer writes these into the systemd unit, or into the Windows Service's config, for you — you normally don't need to touch this directly):
PORT— port to listen on. Default8093.MEDFAM_TIMEZONE— an IANA timezone name (e.g.America/Toronto,America/Los_Angeles,Europe/London). Controls what "today" means for dose scheduling and appointment display. The server refuses to start if this is set to an invalid zone, rather than silently miscomputing every family member's schedule. DefaultAmerica/Toronto.- Changing this after install on Linux: edit
/etc/systemd/system/medfam.service, thensudo systemctl daemon-reload && sudo systemctl restart medfam. - Changing this after install on Windows: port/timezone are baked into the Windows
Service at install time — run
.\install.ps1 -Uninstallthen.\install.ps1 -Port N -Timezone Area/Cityagain (from an elevated PowerShell) to re-register it. - Either way, a tablet with a cached "today" view may take up to 5 minutes to pick up the change (the next automatic revalidation) rather than updating instantly.
- Changing this after install on Linux: edit
- Node.js + Express
better-sqlite3(synchronous, no ORM)- SQLite file at
./data/medfam.db, schema managed by numbered SQL files in./migrations/, applied automatically on startup and tracked in a_migrationstable. - All "what's due today" logic is computed in the configured timezone (see Configuration above); timestamps are stored in UTC in the database.
- Tablet PWA: Vite + React + TypeScript +
vite-plugin-pwa, offline-first via an IndexedDB action queue (see the Phase 2 section below).
npm install
npm run seed # optional: populate 2 sample people/meds/doctors/appointments
npm run dev # starts on http://localhost:8093 with --watchnpm test # backend: node:test
cd pwa && npm install && npm test # PWA: vitestFor the author's own personal Pi deployment workflow (deploy.ps1, one-time Pi setup
notes), see SETUP.md — most contributors and self-hosters want the Quick
Install above instead.
people (id, name, date_of_birth, notes, created_at)
medications (id, person_id, name, brand_name, dosage, color, description, schedule_json, active, created_at)
dose_events (id [client UUID], medication_id, scheduled_date, scheduled_time, taken_at, created_at)
doctors (id, person_id, name, specialty, phone, address, notes, created_at)
appointments (id, person_id, doctor_id, datetime_utc, location, prep_notes, confirmed_at, series_id, recurrence_rule, created_at)
actions (id, person_id, name, category, notes, schedule_json, active, created_at)
action_events (id [client UUID], action_id, scheduled_date, scheduled_time, done_at, created_at)
schedule_json supports two shapes:
{"times": ["08:00", "20:00"], "days": "daily"}
{"times": ["21:00"], "days": ["mon", "wed", "fri"]}Actions track non-appointment, non-medication regimens a person follows on their
own — exercise, self-directed physio, stretching, etc. They use the same
schedule_json shape as medications and the same lazy-generation pattern as
dose_events: action_events rows are created on demand when /today is called,
and marking one done/undone works the same way as taking a dose (see the Actions API
section below).
Recurring appointments are materialized up front, like dose_events are for
medications: creating an appointment with a recurrence rule inserts one row per
occurrence, all sharing a series_id and carrying the same recurrence_rule JSON
({"unit": "week"|"month"|"year", "interval": 1-12, "count": 2-52}). Each occurrence
can be confirmed, edited, or deleted independently.
All endpoints are under /api. All bodies/responses are JSON. Errors return
{"error": "message"} with a 4xx/5xx status.
curl http://localhost:8093/api/healthReturns {"status": "ok", "db": "ok", "timezone": "America/Toronto"} — the PWA reads
timezone from this on startup so it agrees with the server on what "today" means.
# List
curl http://localhost:8093/api/people
# Get one
curl http://localhost:8093/api/people/1
# Create
curl -X POST http://localhost:8093/api/people \
-H "Content-Type: application/json" \
-d '{"name":"Alex Sample","date_of_birth":"1948-03-12","notes":"Prefers morning doses"}'
# Update
curl -X PUT http://localhost:8093/api/people/1 \
-H "Content-Type: application/json" \
-d '{"notes":"Updated note"}'
# Delete
curl -X DELETE http://localhost:8093/api/people/1# List (optionally filter by person)
curl http://localhost:8093/api/medications
curl "http://localhost:8093/api/medications?person_id=1"
# Get one
curl http://localhost:8093/api/medications/1
# Create
curl -X POST http://localhost:8093/api/medications \
-H "Content-Type: application/json" \
-d '{
"person_id": 1,
"name": "Lisinopril",
"brand_name": "Zestril",
"dosage": "10mg",
"color": "#4C6EF5",
"description": "Blood pressure medication. Take with water.",
"schedule_json": {"times": ["08:00"], "days": "daily"}
}'
# Update
curl -X PUT http://localhost:8093/api/medications/1 \
-H "Content-Type: application/json" \
-d '{"schedule_json": {"times": ["08:00", "20:00"], "days": "daily"}}'
# Delete
curl -X DELETE http://localhost:8093/api/medications/1# List (optionally filter by person)
curl http://localhost:8093/api/doctors
curl "http://localhost:8093/api/doctors?person_id=1"
# Get one
curl http://localhost:8093/api/doctors/1
# Create
curl -X POST http://localhost:8093/api/doctors \
-H "Content-Type: application/json" \
-d '{
"person_id": 1,
"name": "Dr. Pat Reyes",
"specialty": "Family Medicine",
"phone": "555-0142",
"address": "123 Main St, Springfield"
}'
# Update
curl -X PUT http://localhost:8093/api/doctors/1 \
-H "Content-Type: application/json" \
-d '{"phone":"555-9999"}'
# Delete
curl -X DELETE http://localhost:8093/api/doctors/1# List (optionally filter by person)
curl http://localhost:8093/api/appointments
curl "http://localhost:8093/api/appointments?person_id=1"
# Get one
curl http://localhost:8093/api/appointments/1
# Create
curl -X POST http://localhost:8093/api/appointments \
-H "Content-Type: application/json" \
-d '{
"person_id": 1,
"doctor_id": 1,
"datetime_utc": "2026-08-01T14:30:00Z",
"location": "123 Main St, Springfield",
"prep_notes": "Bring blood pressure log."
}'
# Create a recurring series (materializes one row per occurrence, sharing a series_id)
curl -X POST http://localhost:8093/api/appointments \
-H "Content-Type: application/json" \
-d '{
"person_id": 1,
"doctor_id": 1,
"datetime_utc": "2026-08-01T14:30:00Z",
"location": "123 Main St, Springfield",
"recurrence": {"unit": "month", "interval": 3, "count": 4}
}'
# Update (edits just this occurrence)
curl -X PUT http://localhost:8093/api/appointments/1 \
-H "Content-Type: application/json" \
-d '{"location":"New clinic address"}'
# Delete just this occurrence
curl -X DELETE http://localhost:8093/api/appointments/1
# Delete this and every later occurrence in its series
curl -X DELETE "http://localhost:8093/api/appointments/1?scope=future"
# Confirm (idempotent)
curl -X PUT http://localhost:8093/api/appointments/1/confirm# List (optionally filter by person)
curl http://localhost:8093/api/actions
curl "http://localhost:8093/api/actions?person_id=1"
# Get one
curl http://localhost:8093/api/actions/1
# Create
curl -X POST http://localhost:8093/api/actions \
-H "Content-Type: application/json" \
-d '{
"person_id": 1,
"name": "Ankle stretches",
"category": "Physio",
"notes": "10 reps each side, hold 15 seconds.",
"schedule_json": {"times": ["09:30", "19:00"], "days": "daily"}
}'
# Update
curl -X PUT http://localhost:8093/api/actions/1 \
-H "Content-Type: application/json" \
-d '{"active": 0}'
# Delete
curl -X DELETE http://localhost:8093/api/actions/1GET /api/people/:id/today — the key endpoint for the tablet app. Returns every
dose and action due today (in the configured timezone), generating any missing
dose_events/action_events rows on read, plus today's appointments and the next 3
upcoming.
curl http://localhost:8093/api/people/1/today{
"date": "2026-07-14",
"doses": [
{
"dose_event_id": "3f1c...uuid",
"medication_id": 1,
"name": "Lisinopril",
"dosage": "10mg",
"color": "#4C6EF5",
"description": "Blood pressure medication. Take with water.",
"scheduled_time": "08:00",
"taken": false,
"taken_at": null
}
],
"actions": [
{
"action_event_id": "6b8f...uuid",
"action_id": 1,
"name": "Ankle stretches",
"category": "Physio",
"notes": "10 reps each side, hold 15 seconds.",
"scheduled_time": "09:30",
"done": false,
"done_at": null
}
],
"appointments_today": [],
"appointments_upcoming": []
}PUT /api/dose-events/:id/taken / PUT /api/dose-events/:id/untaken —
idempotent; safe to call repeatedly (this is what makes offline queue replay from the
tablet safe). Optional body {"taken_at": "2026-07-14T12:05:00Z"}; if omitted, the
server's current time is used. Once a dose is marked taken, repeat calls (even with a
different taken_at) leave the original taken_at untouched.
curl -X PUT http://localhost:8093/api/dose-events/3f1c.../taken \
-H "Content-Type: application/json" \
-d '{"taken_at":"2026-07-14T12:05:00Z"}'
curl -X PUT http://localhost:8093/api/dose-events/3f1c.../untakenPUT /api/action-events/:id/done / PUT /api/action-events/:id/undone — same
idempotent shape as the dose-events endpoints above, for actions instead of
medications.
curl -X PUT http://localhost:8093/api/action-events/6b8f.../done
curl -X PUT http://localhost:8093/api/action-events/6b8f.../undoneGET /api/people/:id/appointments/upcoming?limit=N — next N appointments
(default 5) after now.
curl "http://localhost:8093/api/people/1/appointments/upcoming?limit=3"GET /api/people/:id/doses?from=YYYY-MM-DD&to=YYYY-MM-DD — dose history for the
admin app's compliance view and calendar.
curl "http://localhost:8093/api/people/1/doses?from=2026-07-01&to=2026-07-31"- Lazy dose generation:
dose_eventsrows are only created whentodayis first called for a given (medication, date, time). AUNIQUE(medication_id, scheduled_date, scheduled_time)index plusINSERT OR IGNOREmeans repeated calls never duplicate rows. - Mid-day schedule changes: if a medication's schedule or active status changes
after some of today's
dose_eventsalready exist, the nexttodaycall only adds rows for newly-applicable times — it never touches or removes dose_events already generated (so a dose already marked taken stays marked taken).
A Vite + React + TypeScript PWA: a "Today" screen (meds grouped by time of day, tap-to-mark-taken), an Upcoming Appointments screen, and minimal Settings (text size, switch person). No component library — hand-rolled, large-tap-target components, designed for users with limited tech experience.
Served by this same Express app at / (src/app.js serves pwa/dist as static
files, with an SPA fallback for client routes and /api/* untouched) — same origin as
the API, so no CORS. It picks up the server's configured timezone automatically via
GET /api/health.
cd pwa
npm install
npm run dev # http://localhost:5173, proxying /api -> http://localhost:8093To point at a different backend (e.g. a Pi on your network instead of a local one):
$env:VITE_API_PROXY_TARGET = "http://<your-server-address>:8093"
npm run devcd pwa
npm run build # tsc --noEmit && vite build -> pwa/dist
npm run generate-icons # regenerate placeholder PNG icons in pwa/public/icons- The service worker (via
vite-plugin-pwa) precaches the app shell and runtime-caches/api/*GETs (NetworkFirst, 4s timeout) so a reload while offline still renders something./todayand/appointments/upcomingare additionally cached in IndexedDB by the app itself (pwa/src/db/cache.ts), which is what actually drives the UI — the service worker cache is a second line of defense, not the source of truth. - Taps on "taken"/"untaken"/"confirm" apply optimistically and enqueue an action in
IndexedDB (
pwa/src/db/queue.ts); a background flusher replays the queue in order via the idempotent endpoints whenever the browser comes back online (or every 30s as a fallback, in case connectivity flaps without firing anonlineevent). The queue survives app restarts. /todayis revalidated onvisibilitychange, windowfocus, every 5 minutes while visible, and forced to refetch if the cached response's date no longer matches the current date in the configured timezone (midnight rollover).
Service worker registration (and therefore offline caching and "Add to Home Screen"
installability) only works in a secure context —
HTTPS, or localhost. Plain HTTP over your LAN or a VPN IP will not register a
service worker in Chrome, so none of the offline/install behavior above will
actually engage until the origin is HTTPS. npm run dev and vite preview work fine
for this today because localhost is exempt.
If you're using Tailscale, the easiest fix is tailscale serve, which reverse-proxies
HTTPS on your tailnet to MedFam's plain-HTTP port and handles certificate issuance and
renewal for you — nothing is exposed outside your tailnet. Run once, on the machine
running MedFam (requires MagicDNS + "HTTPS Certificates"
enabled for your tailnet):
./scripts/tailscale-serve.sh # Pi/Linux — defaults to backend port 8093, HTTPS on 443
.\scripts\tailscale-serve.ps1 # WindowsPass a second argument (-HttpsPort on Windows) if 443 is already used by another tailscale serve/funnel target on the same machine — check first with tailscale serve status.
Then point tablets and the Admin app's Server Address at the printed
https://<host>.<tailnet>.ts.net URL instead of http://<tailscale-ip>:8093 — that
also means the tablet and Admin app work this way from anywhere on your tailnet,
not just your home LAN. This is a one-time step; the serve config and certificate
renewal persist across reboots. (tailscale cert <hostname> is the manual
alternative if you'd rather terminate TLS yourself, e.g. directly in Express.)
cd pwa
npm test # vitest: queue enqueue/replay ordering, partial-failure retry,
# non-retryable-action dropping, timezone override, midnight-rolloverQueue and timezone logic is unit-tested (pwa/src/db/queue.test.ts,
pwa/src/lib/timezone.test.ts) against fake-indexeddb. The screens themselves are
verified manually against a running API — there's no UI/component test harness yet.
A Windows desktop app (Electron + React + TypeScript) for day-to-day data management — adding/editing people, medications (with a proper time/days schedule editor), doctors, appointments, and actions (non-appointment, non-medication regimens like exercise or self-directed physio, using the same schedule editor as medications), plus a dose-compliance history view. It's a separate client of the same REST API the tablet PWA uses, not a variant of the PWA itself: it runs on your own Windows machine and connects to your MedFam server's address (asked once on first launch, editable later in Settings) rather than being served same-origin.
Ships as a single Windows installer .exe (via electron-builder) with built-in
update checking (electron-updater, against this repo's GitHub Releases) — it checks
periodically, downloads a newer version in the background if found, and prompts before
restarting to apply it; nothing installs itself without that confirmation.
See admin/README.md for dev mode, building, and the release process.
Issues and pull requests welcome at github.com/ShinobiFPV/MedFam.








