Dial in your perfect brew.
A minimal, mobile-first coffee tracker, made for single dosers: when you weigh in beans per shot and keep several bags open at once, every bean needs its own grinder setting, dose, and brew time. Open Beans keeps every bag on a swipeable shelf β with a photo, a rating, and one auto-saving recipe per brew style β so the next espresso starts where the last one left off, not from memory. Entirely serverless on Cloudflare's free tier: no server to run, nothing to back up, installs like an app.
- Feature Tour
- How It's Built
- Self-Hosting Guide
- Migrating from the Flask Version
- Install It Like an App
- Security Model
- Development
- Project Structure
- License
Every bag you're currently brewing is a full-height card in a snap-scrolling carousel: photo of the bag, a 1β3 star rating, and pills showing which brew styles you've dialled in for it. Swipe between bags, tap one to get to its recipes β and when a bag is empty, one tap archives it.
Archived beans aren't gone β they're the app's memory. Every bag you've ever finished sits in a searchable list with its rating, its style chips, and all of its recipes fully intact.
This is where the app pays off: rebuy a bean months later, hit Restore, and it's back on the shelf with the exact grinder setting, dose, and brew time you'd dialled in last time. No re-dialling, no guessing which of the three-star bags it was β the search box finds it by brand or name.
Each bean holds one recipe per template β single espresso, double espresso, filter β as cards in a carousel. A recipe is four sliders: bean amount, grinder setting, cup weight, and brew time, each with +/β buttons for single-step nudges.
There is no save button. Move a slider and the value is persisted moments later; walk away mid-adjustment and nothing is lost. Ranges, step sizes and starting values all come from the recipe template.
Styles are brew methods β Espresso, Filter, whatever you drink β and group the recipe templates. Templates define a specific brew (e.g. Espresso Small): min, max, step, and default for each of the four sliders. Add as many as you like, drag to reorder them, and each template can be added once per bean.
The theme β light, dark, or follow-the-system β lives here too, stored in the database so every device agrees.
Cleaning a single-doser grinder means unscrewing the burrs, and the zero point drifts every time β a recipe that used to say "dial to 8" might need 6 or 10 to grind the same after a clean. Settings has a running grinder offset for this: tell it what a known setting used to read and what it reads now, and every recipe's "dial to" number shifts automatically. Recipes themselves are never rewritten, so nothing about your dial-in history is lost, and repeated recalibrations compose β no mental math required.
The Worker ships a built-in login gate: enter your passphrase once on each device and a signed cookie keeps you in for a year. Pages, data, and photos are all behind it β no Cloudflare Access, no OAuth dance, no session that expires mid-espresso. Changing the secret logs out every device at once.
No servers, no containers. One Cloudflare Worker serves the
server-rendered React app, gates every request behind the login cookie, and
talks to D1 (SQLite) through Drizzle. Bean photos are downscaled in the
browser before upload and streamed out of a private R2 bucket by the same
Worker. Pushing to main deploys via Workers Builds.
flowchart LR
subgraph Device["π± Browser / installed PWA"]
UI[React UI]
end
subgraph Worker["Cloudflare Worker Β· open-beans"]
Gate[Login gate<br/>signed cookie]
RR[React Router<br/>loaders + actions]
IMG["/images/:key"]
end
UI --> Gate
Gate --> RR
Gate --> IMG
RR -->|Drizzle ORM| D1[(D1 Β· SQLite)]
IMG --> R2[[R2 Β· bean photos]]
UI -.->|hashed JS/CSS/icons| Assets[Static assets]
GH[GitHub push to main] -->|Workers Builds:<br/>migrate + deploy| Worker
| Layer | Choice |
|---|---|
| Framework | React Router 8 (framework mode, SSR) + React 19 + TypeScript |
| Runtime | Cloudflare Workers |
| Database | Cloudflare D1 (SQLite) via Drizzle ORM, migrations via drizzle-kit + wrangler |
| Photos | Cloudflare R2, private bucket; client-side canvas downscale before upload |
| Styling | Tailwind CSS 4, the original app's design tokens ported to @theme |
| Drag & drop | dnd-kit (template reordering) |
| Auth | Hand-rolled passphrase gate in the Worker β HMAC-signed year-long cookie |
| CI/CD | Cloudflare Workers Builds β build, apply D1 migrations, deploy on push |
The schema intentionally keeps the table and column names of the original Flask/SQLAlchemy app, so data from a legacy instance imports without any transformation.
One Worker, one D1 database, one R2 bucket β all comfortably inside Cloudflare's free tier.
- A Cloudflare account
- Node.js 20+ and npm
- A GitHub account, if you want push-to-deploy
git clone <your-fork-url>
cd open-beans
npm installnpx wrangler login
npx wrangler d1 create open-beans-db
npx wrangler r2 bucket create open-beans-imagesCopy the database_id that d1 create prints into
wrangler.jsonc, then create the tables:
npm run db:migrate:remotenpm run db:migrate:local # tables in the local D1 emulator
npm run dev # http://localhost:5173On an empty database the app seeds the default styles (Espresso, Filter) and three recipe templates on first request β add a bean and you're brewing.
Push-to-deploy (recommended). In the dashboard: Workers & Pages β Create β Import a repository, pick your fork, and configure:
| Setting | Value |
|---|---|
| Project name | open-beans (must match name in wrangler.jsonc) |
| Build command | npm run build |
| Deploy command | npx wrangler d1 migrations apply DB --remote && npx wrangler deploy |
Every push to main now builds, applies pending migrations, and deploys.
Future schema changes are just: edit app/db/schema.ts, npm run db:generate, commit.
Or from your machine: npm run deploy.
wrangler.jsoncsetsworkers_dev: falseβ the Worker is only reachable through the custom domain you attach next, and deploys can't silently re-enable theworkers.devURL.
Dashboard β your Worker β Settings β Domains & Routes β Add β Custom domain. Cloudflare creates the DNS record and certificate; the app is live seconds later.
The domain must already be an active zone on your Cloudflare account β
i.e. added under Websites with its nameservers pointed at Cloudflare. A
domain hosted elsewhere won't appear here. (Prefer a workers.dev URL
instead? Remove workers_dev: false from wrangler.jsonc and redeploy β
but set the login gate below first.)
Dashboard β your Worker β Settings β Variables and Secrets β add a
Secret named AUTH_PASSPHRASE with a passphrase you can type on a phone
keyboard. It takes effect immediately β no redeploy.
Until the secret exists the gate is off (fail-open), so a fresh deploy can never lock you out.
Coming from the original self-hosted Flask/SQLite version of Open Beans? Your data ports losslessly. Do this before first visiting the deployed app β an empty database seeds default styles/templates on first request, which would collide with imported IDs (if that happened, the wipe command is below).
# 1. Export the old data as D1-ready SQL (explicit column names,
# image paths rewritten from /static/uploads/* to /images/*)
python3 scripts/export-data.py /path/to/old/instance/db.sqlite > data.sql
# 2. Import into D1
npx wrangler d1 execute open-beans-db --remote --file=data.sql
# 3. Upload the bean photos to R2 (filenames must stay unchanged β
# the database references them, and the file extension determines
# the content type they're served with)
cd /path/to/old/static/uploads
for f in *; do npx wrangler r2 object put "open-beans-images/$f" --file "$f" --remote; doneStop the old container before copying db.sqlite so nothing writes to it
mid-copy. If the app seeded defaults before your import, clear them first:
npx wrangler d1 execute open-beans-db --remote --command \
"DELETE FROM recipe; DELETE FROM recipe_template; DELETE FROM tag; DELETE FROM app_settings;"Databases from before the styles/recipes feature carry recipes that aren't linked to any template β they show up with generic slider ranges and no style. Link them by name, in this order:
-
Open the app once. An imported database with no styles gets the default styles and recipe templates seeded on that first request; nothing exists to link to before it.
-
Look up the real template IDs β don't assume they're 1, 2, 3:
npx wrangler d1 execute open-beans-db --remote \ --command "SELECT id, name FROM recipe_template ORDER BY id;" -
Link the recipes, substituting those IDs:
npx wrangler d1 execute open-beans-db --remote --command \ "UPDATE recipe SET template_id=1, name='Espresso Small' WHERE template_id IS NULL AND name IN ('Small','small'); UPDATE recipe SET template_id=2, name='Espresso Large' WHERE template_id IS NULL AND name='Large'; UPDATE recipe SET template_id=3 WHERE template_id IS NULL AND name='Filter';"
Adjust the names on the right to match whatever your old install called its brews. Anything left unlinked still works β it just falls back to generic slider ranges, and you can point it at a template later.
If step 2 returns no rows, your import brought styles but no templates (seeding skips a database that already has styles): create the recipes you want in Settings β Recipes first, then use their IDs here.
To rehearse the whole thing safely, run the same commands with --local and
check the result with npm run dev.
Open Beans is an installable PWA. On your phone, open your domain in the browser, log in once, and use Add to Home Screen (iOS Safari) or the install prompt (Android Chrome). You get a full-screen app with pull-to-refresh, and the login cookie means you won't see the gate again on that device for a year.
The service worker is deliberately a pass-through β it exists for installability and never caches data, so the app always shows the current state and two devices never disagree.
- Everything dynamic sits behind the login gate. The Worker checks the
cookie before React Router ever runs: pages, form actions, data requests,
and the
/images/*photo proxy all require it. Only the static build assets (JS/CSS bundles, icons, manifest, service worker) are public β Cloudflare serves those ahead of the Worker, and they contain no data. - The cookie is an expiry timestamp signed with HMAC-SHA256 (keyed by
your passphrase),
HttpOnly,Secure,SameSite=Lax, valid for one year. There is no session store to leak or maintain; rotating the passphrase invalidates every cookie instantly. Login responses compare HMACs rather than raw strings, so timing doesn't leak the passphrase. - Fail-open by design, once. With no
AUTH_PASSPHRASEsecret set the gate is disabled β that's what makes the first deploy safe. Set the secret as part of setup and verify you get the login page before putting real data in. - Photos are private. The R2 bucket has no public access; images are
streamed through the authenticated Worker route with immutable cache
headers (keys are unique per upload), plus
nosniffand a sandboxing CSP so a stored file can never execute as a document. Uploads are validated server-side against a MIME allow-list (JPEG/PNG/WebP/GIF/AVIF β notably not SVG, which can carry script) with an 8 MB cap, and downscaled client-side to β€1280 px JPEG. - The database is never exposed. D1 is reachable only through the Worker's Drizzle queries; all mutations are POST actions.
- Single-user by scope. There are no accounts or roles β one passphrase guards one household's coffee data. If you need per-user data or audit trails, put Cloudflare Access in front instead (and accept its session-expiry UX inside an installed PWA).
npm run dev # Vite dev server + local D1/R2 emulators
npm run db:migrate:local # apply migrations to the local database
npm run db:generate # generate a migration from schema.ts changes
npm run typecheck # wrangler types + react-router typegen + tsc
npm run build # production build
npm run deploy # build + deploy from your machinePut AUTH_PASSPHRASE=whatever in .dev.vars (gitignored) to exercise the
login gate locally; leave it unset to skip it.
app/
root.tsx Layout, theme script, pull-to-refresh, SW registration
routes.ts Route config
app.css Tailwind 4 theme β the legacy design tokens
db/
schema.ts Drizzle schema (legacy-compatible names)
index.ts D1 client + first-run seeding
lib/
images.server.ts R2 upload with type/size validation
image-client.ts Canvas downscale before upload
template-form.server.ts Shared recipe-template form parsing
components/ RecipeCard (sliders), StarRating, TemplateForm,
ThemeToggle, carousel dots, icons
routes/ home, bean, add, archive, settings,
template-new/edit, images (R2 proxy), theme
workers/app.ts Worker entry: login gate + React Router handler
drizzle/ Generated SQL migrations (applied by wrangler)
scripts/export-data.py Legacy SQLite β D1 export
public/ PWA manifest, icons, pass-through service worker
docs/screenshots/ README screenshots
wrangler.jsonc Bindings: DB (D1), IMAGES (R2); workers_dev off
MIT Β© Thomas Kleinert β fork it, self-host it, make it yours.



