In production
Photo Album
A collaborative photo album PWA built entirely inside Cloudflare's free tier — one Worker, D1 and R2, no paid services.
The problem
Two people want a shared, private photo timeline without paying for cloud storage or running a server — sharing photos with one other person shouldn't need a monthly bill.
What I built
One Cloudflare Worker serving both the API and the SvelteKit frontend, D1 for data, a private R2 bucket for photos, and a service worker for offline, installable use.


Technical decisions
The whole thing runs behind one Cloudflare Worker — not a separate Pages deployment and a separate API. Requests to /api/* go to a Hono app; everything else falls through to the static SvelteKit build served as Worker assets. I chose that on purpose over splitting frontend and backend into two deployments: two deploys and a CORS setup would have bought nothing here that one Worker with one routing rule doesn’t already give me.
Data lives in D1, with Drizzle as the schema and query layer — migrations are plain SQL files generated from a TypeScript schema, not hand-written each time. For an app storing metadata for two people’s photos, D1’s free-tier ceiling isn’t a real constraint; it’s R2’s storage limit that actually matters, since that’s where the photo bytes live.
The R2 bucket has no public domain at all — there’s no guessable public URL for any photo. A photo is only ever served through an authenticated route: the Worker checks the request’s session, confirms the photo belongs to the caller’s own space, and only then streams the object out of R2 itself, with a strict content-security-policy and a nosniff header on the response. That’s a deliberate choice over generating a signed URL — the access check happens on every single request, not once at link-generation time.
Staying on the free tier meant actually checking its ceilings, not just avoiding anything with a price tag. Password hashing at 100,000 PBKDF2 iterations risked hitting the free tier’s per-request CPU-time cap on login, so I measured it in production instead of guessing — it came back at under half a second, comfortably inside the limit — and kept a documented fallback ready (fewer iterations, a versioned hash format) in case a future login pattern pushes it over.
There’s no open sign-up. The first account is created through a one-time setup route, and the only way anyone else gets in is a single-use invite link that expires after seven days — the link itself is never stored, only its hash. Every query in the API is scoped to the caller’s own space, and a photo that belongs to someone else’s space returns the exact same 404 as a photo that doesn’t exist at all, so there’s no way to tell the two apart from the outside.
Outcome
Live and installable as a PWA, running end to end on Cloudflare's free tier, with no separate hosting bill.
Stack
- Cloudflare Workers
- D1
- R2
- Hono
- SvelteKit
- TypeScript
- Drizzle ORM