A symptom-first triage guide. It assumes the Option A topology from remote access: Flask on a Raspberry Pi at home, a Cloudflare Tunnel to a public hostname, optionally Cloudflare Access in front of it, and Tailscale for admin access. If you're on Option B, the Flask and backup sections still apply — the Cloudflare ones don't.
Everything here is organized by what you actually see first, not by which component failed. That's deliberate: several very different faults present as "the site is down", and the fastest way out is to tell them apart before you start fixing.
Placeholders
Substitute your own values throughout. Where an address appears, x stands in for a digit.
| Placeholder | Yours will look like |
|---|---|
app.yourdomain.com |
the public hostname routed to your tunnel |
<pi-hostname> |
your Pi's hostname, e.g. what you set at first boot |
192.168.x.x |
your actual LAN IP for the Pi — find it with ip addr on the Pi, or ping <pi-hostname>.local |
100.x.x.x |
your actual Tailscale IP for the Pi — tailscale ip -4 on the Pi |
<your-team-name> |
your Zero Trust team name, <team>.cloudflareaccess.com |
The map
CLIENTS CLOUDFLARE EDGE HOME (behind CGNAT)
you, a browser ------> +---------------------+
MFA + login | Access gate |
| whole hostname |
clinician ------> +---------------------+ +------------------+
token in URL | Bypass /portal/* | ====> | Pi <pi-hostname> |
+---------------------+ tunnel| flask :5000 |
phone/wearable ------> | Bypass /api/* | | cloudflared |
bearer token +---------------------+ | tailscaled |
+--------+---------+
|
v
biotracking.db
---------------------------------------------------------------------------
you, admin (ssh) ---+
+--- Tailscale, does not touch Cloudflare ---> (same Pi)
backup machine ---+
The two halves share only their destination. A Tailscale fault and a Cloudflare fault are never the same fault — an expired Tailscale key takes out SSH and your backups while the public site keeps serving perfectly, and a tunnel or Access problem does the reverse. Half of triage is working out which half you're in.
Three front doors, one database
If you've added Access and the portal, three independent credentials now reach the same SQLite file. They're issued, revoked and broken separately — changing your own password does nothing to the other two.
| Who | Credential | Skips | Revoke by |
|---|---|---|---|
| You | Access identity + MFA, then the Flask login | nothing | Access policy, or change the password |
| Clinicians | capability token in the URL, time-limited, logged | Access and the Flask login | /portals → revoke |
| Phone apps | api_token bearer header |
Access and the Flask login | rotate in config.json, re-enter on each device |
| A device / wearable | wearable_token bearer header |
Access and the Flask login | rotate in config.json, then reflash the device |
A — the site won't load
-
Check it cold, from cellular rather than your home WiFi.
curl -sI https://app.yourdomain.com/ | head -1A
302toward<your-team-name>.cloudflareaccess.comis healthy — that's the Access gate doing its job, not an error. -
A Cloudflare error 1033 or an Argo Tunnel error page means the edge is up but
cloudflaredisn't connected. The fault is on the Pi side — skip to step 4. -
Get to the Pi. Try the LAN name first; it works even when Tailscale doesn't.
ssh you@<pi-hostname>.localNo route at all? That's section B, a different failure.
-
On the Pi, separate Flask from the tunnel.
systemctl status cloudflared curl -sI localhost:5000/login | head -1Flask
200with cloudflared dead is a tunnel fault. No200means the app itself stopped — restart it. -
Everything up but the browser is refused? That's an Access policy fault, not infrastructure. Confirm your applications still hold their destinations.
B — you can't SSH to the Pi
-
Name the cause before assuming the Pi is down.
tailscale ping <pi-hostname>peer's node key has expiredis Tailscale's periodic node-key expiry (180 days by default). The Pi is fine and the public site is still serving. Only admin access and anything running over Tailscale — like a backup pull — are broken.This one is worth internalizing:
tailscale statusreports the machine asoffline, which reads exactly like a dead Pi. It isn't. The Pi is still checking in with the control plane; peers just can't build a connection to it. -
Recover over the LAN. Tailscale is the broken thing, so don't route through it.
ssh you@<pi-hostname>.local # 192.168.x.xIf your
~/.ssh/configpoints the short host name at the Tailscale address, the short name will fail during an expiry. Use the.localname or the LAN IP. -
Bring the tailnet back from on the Pi, then open the URL it prints.
sudo tailscale up -
Durable fix: Tailscale admin console → Machines → your Pi → Disable key expiry. Key expiry on an always-on server is a trap — it locks you out of the exact machine you need Tailscale to reach.
-
If
<pi-hostname>.localwon't resolve either, the Pi really is down. Power, storage, or an unclean reboot.
C — backups have stopped arriving
Two different faults produce the same silence, and the logs tell them apart.
-
Read the receiving side first.
tail -5 ~/backups/pi-biotracking/pull.logFAILlines mean a network or Tailscale partition. The snapshots still exist on the Pi and backfill on recovery — nothing is lost. -
If the pull looks clean, check whether the snapshot was ever made.
ssh <pi-hostname> 'tail -5 ~/backups/biotracking/backup.log'A missing night means the Pi was down through the backup window. That snapshot is gone permanently — but no health data is lost, because the live database is cumulative. You lose a restore point, not records.
-
If you schedule the snapshot with
cron, a reboot through the backup window skips it silently and never catches up. A systemd timer withPersistent=truefires the missed run on the next boot instead. Worth the switch if your power isn't reliable.
D — someone says their portal link is dead
-
Ask what they see. A Cloudflare login page means your Access carve-out broke. Your own 403 page means the token expired or was revoked — ordinary, and expected.
-
Test the carve-out cold. No real token needed:
curl -s -o /dev/null -w '%{http_code}\n' https://app.yourdomain.com/portal/badtoken403is correct — traffic reached the Pi. A302means your bypass application lost its/portal/*destination. -
If it's only expiry, issue a fresh link from
/portals. The old one stays revoked and the access log keeps its history.
E — a phone or device stopped syncing
-
Prove the lane still reaches Flask. Use POST — this matters:
curl -s -X POST -H 'Content-Type: application/json' -d '{}' \ https://app.yourdomain.com/api/health-sync{"error":"unauthorized"}is correct: Access let it through and the bearer check ran. Getting HTML back means the/api/*bypass broke. -
A GET on that same route returns
302to/login, not401. That'srequire_loginseeingrequest.endpointasNoneon a 405 method mismatch — not an Access fault, and not worth chasing. Always test with POST. -
If it's only one device, suspect the device before the server — especially anything with flaky WiFi or no real-time clock. Check when its last ingest actually landed.
The cold check
Run this after any change to Access, your tunnel, or your DNS. From something with no cookies and no session:
| Request | Expect | What it proves |
|---|---|---|
/ |
302 to your team domain |
the gate is live |
/portals |
302 to Access |
management is gated — the wildcard didn't leak |
/portal/badtoken |
403 from the app |
the portal bypass works |
POST /api/health-sync |
401 JSON |
the API bypass works, bearer auth still enforced |
Both carve-out failures are silent — nothing alarms, and you find out when someone emails you or when a week of device data turns out to be missing. That's the whole reason to run the check rather than assume.
Reading this offline
Several of the failures above are "the network is down" or "you can't reach the Pi",
which is an awkward moment to need a web page. make-manpage.py in this repo renders
this file as a man page, so it stays readable from a terminal with nothing else
working:
python3 make-manpage.py --install
man sardinetracker
No dependencies beyond Python 3 — it renders the markdown directly rather than pulling in a converter.
Optionally, create a runbook-site.md next to it holding your own installation's
details. It is folded in as the opening section, and a ## Substitutions table in it
replaces the placeholders above with your real values throughout, so the commands can
be pasted without editing:
## Substitutions
| Placeholder | Real |
|---|---|
| `app.yourdomain.com` | `app.example.com` |
| `<pi-hostname>` | `raspberrypi` |
That file is gitignored. Keep your own addresses out of version control.