Architecture · v2.1.1

How it actually works.

Every moving part, in plain English. No hand-waving.

01
Overview

The one-sentence summary

Your iPhone talks to a FastAPI server running on your Raspberry Pi. The Pi opens an outbound tunnel to Cloudflare, so your phone reaches it via cloud.ughstorage.com without your home router exposing anything. Files live on an NVMe drive physically attached to the Pi. Supabase (a Postgres-as-a-service) holds only your account + per-device metadata — never file contents.

iPhone
SwiftUI · iOS 17+
Cloudflare
TLS edge
Tunnel
cloudflared
Pi 5
FastAPI :8000
NVMe
ext4 · /mnt/nvme
02
Networking

Cloudflare Tunnel — the outbound-only trick

Normally to reach a server behind a home router, you'd port-forward (open TCP 443) and give out your IP. That's a hole in your firewall, permanently. Cloudflare Tunnel inverts this: a daemon on the Pi (cloudflared) opens outbound connections to Cloudflare's edge, the same way your browser connects to Gmail. Cloudflare assigns you a subdomain (cloud.ughstorage.com), accepts HTTPS there, and routes it back over the existing tunnel.

Result: zero open ports on your router. Your firewall sees only outbound HTTPS, which it lets through by default. Hostile scanners can't find your Pi because it has no public-facing address.

TLS is end-to-end
Cloudflare sees your traffic shape (request timing, bytes transferred) but not contents — the TLS handshake runs between your iPhone and the Pi's server. Cloudflare's certificate chain is just a passthrough.
03
First-time setup

Bluetooth provisioning

A brand-new Pi has no WiFi credentials and no way to know which user owns it. Instead of typing IPs into a web form, Ugh! Storage does pairing over BLE (Bluetooth Low Energy). The Pi advertises as UghStorage-Setup; the iOS app connects directly.

The handshake (in order):

  1. App scans BLE, finds the Pi → shows it in the "Add Device" screen.
  2. App requests a WiFi scan → Pi runs nmcli device wifi list and replies with available networks.
  3. User picks a network + enters password in the app → sent over BLE (encrypted).
  4. Pi connects via NetworkManager → reports success.
  5. App sends two things: the user's Supabase auth token + the provisioning token.
  6. Pi calls Supabase's register-device edge function with both.
  7. Edge function validates the provisioning token, creates a devices row, provisions a Cloudflare tunnel, returns {device_id, shared_secret, tunnel_token}.
  8. Pi writes those to .env, starts cloudflared, starts the FastAPI service.

The app then disconnects from BLE and switches to HTTPS over the tunnel. Total time: ~45 seconds.

04
New in 2.1.1

The heartbeat edge function

Every 5 minutes the Pi sends a heartbeat to Supabase: "I'm alive, here are my storage stats." Earlier versions tried to do this by PATCHing the devices table directly — but row-level security (RLS) on the table requires auth.uid() = owner_id, and the Pi has no Supabase user session. So PostgREST silently rewrote every UPDATE to match zero rows and returned 200 OK. The Pi thought it was succeeding; Supabase never saw a single heartbeat.

2.1.1 moves the heartbeat through a dedicated Edge Function. The Pi POSTs {device_id, shared_secret, storage_total, storage_used} to /functions/v1/heartbeat. The function uses constant-time comparison to validate the secret against the row, then uses the service role internally to update last_seen_at, is_online, and the storage stats. RLS is bypassed safely — the shared_secret check is the auth.

Now visible in the iOS app
Settings → Status row shows a real-time green dot. A live probe on Settings-open combines with Supabase's cached flag so a Pi that just went offline flips to Offline immediately instead of staying "stale Online" for up to 5 minutes.
05
Identity

JWT + per-device secrets

Every request from the iOS app to your Pi carries a JWT signed with your Pi's unique shared secret (64 hex chars, generated at registration). The Pi's require_auth() middleware verifies the signature using HS256.

Key properties:

  • Stealing a JWT from one device's traffic doesn't let you into anyone else's Pi — every Pi has its own secret.
  • The secret is stored only on the Pi (in .env, mode 600) and in Supabase (for the heartbeat check). Never leaves either.
  • Rotation: UPDATE devices SET shared_secret = … + update Pi's .env. Invalidates all outstanding tokens instantly.
06
On the Pi

Three services, three jobs

Everything runs under systemd with Restart=always, so a crash or reboot is self-healing.

  • ughstorage.service — FastAPI on port 8000. File upload/download, search, thumbnails, HLS transcoding for videos, trash + favorites + share links, heartbeat loop, OTA update mechanism.
  • ughstorage-ble.service — Python BLE GATT server using dbus-next + BlueZ. Only handles first-time pairing; otherwise idle.
  • cloudflared.service — Cloudflare's tunnel daemon. Maintains 4 persistent QUIC connections to Cloudflare edge locations for redundancy.
07
Storage layer

Files + database

Three directories on /mnt/nvme:

  • /mnt/nvme/storage/ — original files, organized by virtual path.
  • /mnt/nvme/thumbnails/ — auto-generated 300×300 JPEG thumbs (Pillow for images, ffmpeg first-frame for video).
  • /mnt/nvme/hls/ — pre-transcoded HLS streams for instant video playback (capped at 2 concurrent ffmpegs to avoid thrashing the Pi).

Metadata lives in a single SQLite database (/mnt/nvme/ughstorage.db) in WAL mode for concurrent reader access. Schema: files, favorites, trash, share_links, hls_tokens. Everything else is derivable from the filesystem.

08
Updates

Signed OTA updates

The Pi polls a signed manifest.json on the public stable branch. Each manifest is Ed25519-signed by a private key that lives only on the release-cutting machine. The public key is baked into update_manager.py on every Pi, so even if GitHub were compromised and the manifest replaced, your Pi rejects the bogus signature.

When you tap Update in the iOS app: the Pi runs update.sh, which does git fetch, pip install, systemctl restart, then polls /health for 60 seconds. If health fails, it automatically rolls back to the previous SHA.

09
Defense in depth

The security model

Five layers, in order of the attacker's path:

  1. Transport encryption — TLS 1.3 iPhone↔Cloudflare, plus TLS Cloudflare↔tunnel, plus HTTPS within the tunnel. Three independent layers.
  2. Network isolation — zero open ports; Pi is invisible to port scans. Cloudflare absorbs DDoS before it reaches your home connection.
  3. Auth — per-device JWT + unique 64-byte shared secret. Can't use one Pi's token on another.
  4. Path safety — every user-supplied path is lexically sanitized and symlink-resolved before I/O. Can't escape /mnt/nvme/storage/.
  5. Physical security — the only copy of your files exists on a drive in your home. No cloud backup means no cloud breach.
10
API surface

What the server exposes

All require JWT auth except /health (unauthenticated liveness) and /share/{token} (public, token-gated).

GET  /health                      # liveness + version
GET  /modules                     # list enabled modules
GET  /storage/usage-breakdown     # bytes by module

POST /files/upload                # stream upload with SHA-256
GET  /files                       # list with pagination
GET  /files/{id}/download         # original
GET  /files/thumbnail/{id}        # 300x300 JPEG
POST /files/{id}/favorite         # toggle star
POST /files/{id}/trash            # soft delete
POST /files/{id}/restore          # from trash
DELETE /files/{id}                # permanent
POST /files/{id}/share            # expiring link
POST /files/{id}/hls-token        # 12h streaming token
GET  /hls/{token}/{path}          # HLS segments

GET  /search?q=&type=             # FTS5 + type filter
GET  /device/stats                # CPU, RAM, disk, WiFi, uptime
GET  /system/version              # firmware version
GET  /system/update/check         # poll manifest
POST /system/update/apply         # trigger OTA

Tech stack: FastAPI 0.115, Uvicorn (ASGI), aiosqlite, PyJWT (HS256), Pillow, ffmpeg, psutil, PyNaCl (manifest sig verify), aiohttp.

Want to read the actual source?

Browse the repo on GitHub