How I used Cloudflare Pages + GitHub API as a free backend for a family caregiving app
My grandfather has chronic heart and respiratory disease. He needs oxygen 24/7, morphine, and a palli
ative care team visiting regularly. Coordinating between professional caregivers, family members spread across different cities, and his medical team was a constant mess of WhatsApp messages, forgotten medication doses, and nobody knowing who was there last Tuesday.
I built Cuida to fix this. It's a PWA that runs entirely on free-tier infrastructure — no monthly bills, no third-party databases, no vendor lock-in.
The problem: coordinating home care is a mess
When someone in your family needs continuous care, you quickly end up with:
- A shared WhatsApp group that's too noisy to be useful
- Nobody knowing the exact medication schedule (especially doses and why each drug exists)
- No clear protocol for when something goes wrong at 2am
- Professional caregivers with no written handoff from the family
- Doctors asking "how has he been this week?" and nobody has notes
What you need is a shared dashboard that everyone — family, caregivers, doctors — can check from their phone in 10 seconds.
Why not use an existing app?
I looked. The options were:
- Generic care apps: US-centric, $30-50/month, required uploading patient data to their servers
- Note-sharing apps (Notion, Google Docs): no structure, no push notifications, not mobile-optimised
- WhatsApp: already failing us
The core requirements were: free, private (data doesn't leave the family), works offline, push notifications without keeping the app open, and something I could customise for our specific situation (oxygen, morphine, PADES palliative team).
So I built it.
The architecture: Cloudflare Pages + GitHub API as a database
The stack is deliberately boring:
- Frontend: vanilla HTML + CSS + JS (no framework, no build step)
- Backend: Cloudflare Pages Functions (serverless, runs at the edge)
- Database: a JSON file in a private GitHub repo, read/written via GitHub API
- Push notifications: Web Push / VAPID (W3C standard, no Firebase)
- Cron jobs: GitHub Actions
Total cost: €0/month.
Data sync: reading and writing JSON via GitHub API
This is the part people find surprising. There's no database. All patient data lives in a single JSON file (app/dades.json) in a private GitHub repo. The Cloudflare Pages Function reads and writes it via the GitHub API.
Reading data (GET):
// functions/api/dades.js
export async function onRequest(context) {
const { env } = context;
const url = `https://api.github.com/repos/${REPO}/contents/app/dades.json`;
const res = await fetch(url, {
headers: {
'Authorization': `token ${env.GITHUB_TOKEN}`,
'Accept': 'application/vnd.github.v3+json',
}
});
const data = await res.json();
const content = JSON.parse(atob(data.content));
return new Response(JSON.stringify(content), {
headers: { 'Content-Type': 'application/json' }
});
}
Writing data (POST) is the same but includes the current file SHA (required by GitHub API to avoid conflicts) and creates a commit:
// Get current SHA first, then write
const writeRes = await fetch(url, {
method: 'PUT',
headers: { 'Authorization': `token ${env.GITHUB_TOKEN}`, ... },
body: JSON.stringify({
message: 'Actualització dades',
content: btoa(JSON.stringify(newData)),
sha: currentSha,
})
});
Every write creates a real git commit. You get a full audit trail of every change for free.
Tradeoffs:
- GitHub API has a rate limit (5000 req/hour with auth — more than enough for a family app)
- Not suitable for high-frequency writes (but caregiving data changes a few times a day at most)
- SHA conflict if two devices write simultaneously — we handle this with a
pull --rebasediscipline
Push notifications with Web Push / VAPID (without Firebase)
Most tutorials send push notifications through Firebase Cloud Messaging. We skip that entirely and use the W3C Web Push standard directly.
Setup:
node scripts/generar-claus-vapid.js
# Outputs: VAPID_PUBLIC_KEY and VAPID_PRIVATE_KEY (JWK format)
Subscribing from the browser:
const registration = await navigator.serviceWorker.ready;
const subscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(VAPID_PUBLIC_KEY),
});
// POST subscription to /api/suscripcions → saved in data/subs.json via GitHub API
Sending a notification (from Cloudflare Function):
import { encrypt } from 'web-push'; // or implement VAPID signing manually
for (const sub of subscriptions) {
await fetch(sub.endpoint, {
method: 'POST',
headers: {
'Authorization': vapidAuthHeader,
'Content-Type': 'application/octet-stream',
'Content-Encoding': 'aes128gcm',
'TTL': '86400',
},
body: encryptedPayload,
});
}
It works on Android and iOS 16.4+ (when installed as a PWA). No Firebase account, no SDK, no data going through Google's servers.
Cron jobs with GitHub Actions
Medication reminders run 3 times a day. The "I'm alone" safety check runs every 15 minutes. Both are GitHub Actions workflows that call a Cloudflare Pages Function endpoint.
# .github/workflows/notificacions.yml
on:
schedule:
- cron: '0 6 * * *' # 8:00 local time
- cron: '30 11 * * *' # 13:30 local time
- cron: '0 19 * * *' # 21:00 local time
jobs:
enviar-push:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: |
TOKEN=$(cat .github/workflows/_cron-token.txt)
curl -X POST "${CUIDA_URL}/api/notificacions" \
-H "X-Cron-Token: ${TOKEN}"
Auth for crons: why a repo-stored token beats a shared secret
This deserves its own section because I wasted two days on it.
What I tried first: a shared secret stored as a GitHub Actions secret and a Cloudflare environment variable. Sounds simple. In practice, keeping two values in two independent systems byte-identical is surprisingly hard. Cloudflare doesn't apply env var changes until the next deploy. GitHub Actions masks secrets in logs, so you can't verify the value. I synced it manually 4+ times and it kept failing.
What I tried second: GitHub OIDC — the workflow requests a signed JWT from GitHub, the Cloudflare function verifies it cryptographically against GitHub's public JWKS. Elegant, but I had a subtle bug: atob() requires standard base64 but JWTs use base64url (- and _ instead of + and /). The decode failed silently inside a catch {} block.
What actually works: the token lives in one file in the repo (_cron-token.txt). The workflow reads it via actions/checkout. The Cloudflare function has the same value hardcoded as a constant — deployed from the same repo, same commit. They are literally the same file. It's impossible for them to desync.
// functions/api/notificacions.js
const CRON_TOKEN = 'your-hex-token-here';
export async function onRequest({ request, env }) {
const received = (request.headers.get('X-Cron-Token') || '').trim();
if (received !== CRON_TOKEN) return new Response('Unauthorized', { status: 401 });
// ... send push notifications
}
The repo is private, so the token is as secret as any environment variable.
The "I'm alone" safety mode
This is the feature that required the most thinking. When the patient is home alone, they activate a 1–4 hour timer. If they don't press "I'm OK" before the timer runs out, the whole family gets a push notification.
The state is simple:
"estic_sol": {
"actiu": true,
"fins": "2026-05-31T15:00:00.000Z",
"activat": "2026-05-31T13:00:00.000Z",
"alerta": false
}
The GitHub Actions cron runs every 15 minutes and calls /api/estic-sol-check. The function reads the current state, checks if fins has passed, and if so sets alerta: true and sends SOS push notifications to all subscribers.
The "I'm OK" button calls /api/estic-be — no authentication required, since the worst an attacker can do is tell us the patient is fine.
PWA: service worker, offline support, installable
The service worker caches all static assets. When offline, the app shows the last loaded data (useful when the caregiver arrives somewhere with no signal and needs the emergency protocols).
The push handler in the service worker decides what notification to show based on the data:
self.addEventListener('push', event => {
const data = event.data?.json();
if (data.estic_sol?.alerta) {
// SOS notification — requireInteraction: true, max urgency
} else {
// Medication reminder — show which pills for this meal
}
});
On iOS, the app must be installed as a PWA (Add to Home Screen) for push notifications to work. On Android, it works from the browser.
What I'd do differently
Configurable notification times. They're hardcoded in the GitHub Actions cron schedule right now. It works, but changing them requires editing a YAML file.
Conflict handling for concurrent writes. If two family members edit data simultaneously, the second write fails with a GitHub API SHA mismatch. Right now you just retry. A proper solution would use optimistic locking.
iCal integration for the caregiver schedule. We store the caregiver schedule as structured data, but importing it from an existing calendar would save a lot of manual entry.
Try it / fork it
The repo is public, MIT licensed, and has a detailed deploy guide:
Deploy takes about 20 minutes following the README. You'll need a Cloudflare account (free tier is more than enough) and a GitHub account for the private data repo.
If you're coordinating care for someone at home, I hope it's useful.
Built by LinuxBCN. Made from a real situation — my grandfather is doing OK.




Top comments (0)