Cloudsteading
Bean carousel with photo, rating and style pills

Open Beans

Keep coffee bags, grinder settings and brew recipes in one private web app.

Open Beans is a self-hosted Notion alternative built on Cloudflare (D1, R2, Workers). Free tier eligible within limits. Inspect the source and license in the linked repository.

Source & license

Upstream license: MIT

License TL;DR

You can use it, change it, self-host it and sell it. Keep the original copyright and license notice with copies of the code. You don’t have to publish your changes. The authors don’t promise it will work.

Explain MIT in plain English →

Summary of the main license. Separate packages and assets can have different terms.

Inspect repository ↗Read this project’s actual license ↗

Repository owner

@thkleinert

See the upstream repository for the original creator and contributors.

Maintain this project? Maintainer verification →

Cloudflare hosting

Free tier eligible within limits

The reviewed Cloudflare deployment is eligible for Free-plan allowances for the stated small workload and feature scope. Usage limits, CPU, required account setup and separate services apply.

Hosting requirements
  • Single-household deployment only: configure AUTH_PASSPHRASE before adding data and verify the gate; it is fail-open until configured.
  • Replace the upstream D1 ID, activate R2 and keep small compressed photos within Standard storage/operation quotas.
  • Keep Worker requests/CPU and D1 rows/storage below free limits; all image reads go through the Worker.
  • The default workers_dev:false requires an owned custom domain; alternatively enable workers.dev before deployment to avoid a mandatory domain purchase.
  • No external AI, mail or OAuth provider is needed; the PWA requires connectivity for data.
  • Workers Free dynamic requests are shared across this account (100,000/day), with 10 ms CPU per invocation; workload fit is conditional and has not been measured.
  • D1 Free allowance: 5 million rows read/day, 100,000 rows written/day and 5 GB total storage; unindexed scans and history retention consume quota.
  • R2 Standard allowance: 10 GB-month storage, 1 million Class A and 10 million Class B operations/month; account activation may require billing setup, and other storage classes are excluded.
Check current pricing ↗
Sources checked 01/10/2026

Repository snapshot: 1258aea. Hosting eligibility reflects the deployment documentation and listed assumptions.

  • notion ↗

    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. [![React Router 8](https://img.shields.io/badge/React%20Router-8-f44250?logo=reactrouter&logoColor=white)](https://reactrouter.com) [![TypeScript](https://img.shields.io/badge/TypeScript-5.9-3178c6?logo=typescript&logoColor=white)](https://www.type

  • workers ↗

    modules/wrangler/config-schema.json", "name": "open-beans", "compatibility_date": "2026-07-30", "main": "./workers/app.ts", // Only serve on the custom domain; never re-enable workers.dev on deploy. "workers_dev": false, "preview_urls": false, "observability": { "enabled": true }, "upload_source_maps": true, "compatibility_flags": [ "nodejs_compat" ], "d1_databases": [ { "binding": "DB", "database_name": "open-beans-db", "database_id": "24546312-5f82-487a-bbf4-ce8c751c7872", "migrations_dir": "./drizzle" } ], "r2_buckets": [ {

  • d1 ↗

    _maps": true, "compatibility_flags": [ "nodejs_compat" ], "d1_databases": [ { "binding": "DB", "database_name": "open-beans-db", "database_id": "24546312-5f82-487a-bbf4-ce8c751c7872", "migrations_dir": "./drizzle" } ], "r2_buckets": [ { "binding": "IMAGES", "bucket_name": "open-beans-images" } ] }

  • r2 ↗

    7a-bbf4-ce8c751c7872", "migrations_dir": "./drizzle" } ], "r2_buckets": [ { "binding": "IMAGES", "bucket_name": "open-beans-images" } ] }

  • free-tier-eligible ↗

    com/workers/wrangler/configuration/ */ { "$schema": "node_modules/wrangler/config-schema.json", "name": "open-beans", "compatibility_date": "2026-07-30", "main": "./workers/app.ts", // Only serve on the custom domain; never re-enable workers.dev on deploy. "workers_dev": false, "preview_urls": false, "observability": { "enabled": true }, "upload_source_maps": true, "compatibility_flags": [ "nodejs_compat" ], "d1_databases": [ { "binding": "DB", "database_name": "open-beans-db", "database_id": "24546312-5f82-487a-bbf4-ce8c751c7872",

  • free-tier-eligible ↗

    ount Manager. | | Requests<sup>1, 2, 3, 4</sup> | Duration | CPU time | | --- | --- | --- | --- | | **Free** | 100,000 per day | No charge for duration | 10 milliseconds of CPU time per invocation | | **Standard** | 10 million included per month <br> +$0.30 per additional million | No charge or limit for duration | 30 million CPU milliseconds included per month<br> +$0.02 per additional million CPU milliseconds<br><br> Max of [5 minutes of CPU time](https://developers.cloudflare.com/workers/platform/limits/#account-plan-limits) per invocation (default: 30 second

  • free-tier-eligible ↗

    rs Paid](https://developers.cloudflare.com/workers/platform/pricing/#workers) | | --- | --- | --- | | Rows read | 5 million / day | First 25 billion / month included + $0.001 / million rows | | Rows written | 100,000 / day | First 50 million / month included + $1.00 / million rows | | Storage (per GB stored) | 5 GB (total) | First 5 GB included + $0.75 / GB-mo | Track your D1 usage To accurately track your usage, use the [meta object](https://developers.cloudflare.com/d1/worker-api/return-object/), [GraphQL Analytics API](https://developers.cloudflare.com/d1/obs

  • free-tier-eligible ↗

    f you have retrieved data (for infrequent access storage) for 1.1 GB, you will be billed for 2 GB. ### Free tier You can use the following amount of storage and operations each month for free. | | Free | | --- | --- | | Storage | 10 GB-month / month | | Class A Operations | 1 million requests / month | | Class B Operations | 10 million requests / month | | Egress (data transfer to Internet) | Free <sup>[1](#user-content-fn-1)</sup> | Caution The free tier only applies to Standard storage, and does not apply to Infrequent Access storage. ### Storage usage S

  • MIT ↗

    MIT License Copyright (c) 2026 Thomas Kleinert Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this

  • architecture ↗

    modules/wrangler/config-schema.json", "name": "open-beans", "compatibility_date": "2026-07-30", "main": "./workers/app.ts", // Only serve on the custom domain; never re-enable workers.dev on deploy. "workers_dev": false, "preview_urls": false, "observability": { "enabled": true }, "upload_source_maps": true, "compatibility_flags": [ "nodejs_compat" ], "d1_databases": [ { "binding": "DB", "database_name": "open-beans-db", "database_id": "24546312-5f82-487a-bbf4-ce8c751c7872", "migrations_dir": "./drizzle" } ], "r2_buckets": [ {

Upstream screenshot · thkleinert/open-beans repository contributors ↗. Depicts the upstream project. We have not deployed and tested a fresh installation here.

What it can replace

Compare the workflow you need. These mappings describe overlap; full feature parity requires a separate comparison.

Notion logoNotion ↗

Editorial workflow alternative: A purpose-built replacement for a personal coffee-bag and brew-recipe table; no general-purpose document or team-workspace parity.

See supporting source ↗
external SaaS target
varies
→ D1 + R2 + Workers

How it works

The shape of Open Beans on Cloudflare, and how it stacks up against the rented tools it replaces.

Architecture

Diagram of deployment declarations at the reviewed commit. Each app has its own entrypoint; declared resources do not prove runtime calls. Follow file and line sources below.

View upstream source ↗
Public interface
Configured entry points1
open-beans
wrangler.jsonc
↓
App
open-beans
entry
Cloudflare Workers
Entrypoint: ./workers/app.ts
↓

Configuration and workflow sources

Reviewed commit 1258aea02bdf. Files were read as data; upstream applications and CI jobs were not executed.

Deployment configuration · 1 files
wrangler.jsonc ↗

Cloudflare Workers · compatibility 2026-07-30

open-beans · default

Entrypoint: ./workers/app.ts

  • DB → D1
  • IMAGES → R2

Named environments are separate deployments. Bindings are shown only where declared. Configured routes are URL patterns, not verified application endpoints.

Runtime source · handlers, binding usage and workflow steps

Observed TypeScript/JavaScript declarations from Worker entrypoints and resolved relative imports. Calls and workflow steps may run conditionally; their listed order is not a proven end-to-end request flow. Router declarations may be mounted under a prefix or may not be registered. This shows code wiring, not a successful deployment or runtime test. Dynamic wiring, aliases and generated code may not resolve.

workers/app.ts ↗
  • L110 · fetch handler exported · references AUTH_PASSPHRASE · calls request.formData, String, form.get, hmac, loginPage, authCookie, isAuthed, Response.redirect, toString, url.pathname.endsWith, requestHandler
  • L19 · hmac calls (conditional paths may differ): crypto.subtle.importKey, encoder.encode, crypto.subtle.sign, join, map, padStart, b.toString
  • L32 · safeEqual calls (conditional paths may differ): a.charCodeAt, b.charCodeAt
  • L39 · isAuthed calls (conditional paths may differ): request.headers.get, cookies.match, split, Number, Date.now, safeEqual, hmac
  • L103 · authCookie calls (conditional paths may differ): Math.floor, Date.now, hmac

Environment references: env.MODE · env.AUTH_PASSPHRASE

Build and deployment pipeline · 0 GitHub Actions workflows

Repository CI declarations, separate from runtime request processing. Job dependencies and conditions are shown as written; long commands are shortened with an ellipsis; a workflow file does not prove a recent successful run.

No GitHub Actions workflow was found in the collected tree. Deployment may be manual or configured elsewhere.

package.json ↗
  • build: react-router build
  • deploy: npm run build && wrangler deploy

Full upstream document by @thkleinert · README.md · snapshot 1258aea

Open Beans logo

Open Beans

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.

React Router 8 TypeScript Cloudflare Workers D1 Drizzle Tailwind CSS 4 PWA License: MIT


Bean carousel with photo, rating and style pills  Recipe card with auto-saving sliders  Settings with styles and recipe templates


Contents


Feature Tour

🫘 Your Open Bags, One Shelf

Bean carousel

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.


📦 The Archive Remembers Every Bag

Searchable archive with ratings and restore

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.


⏱️ Recipes That Save Themselves

Recipe sliders

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 and Templates, Yours to Shape

Settings

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.


🌀 Recalibrate After a Clean

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.

🔐 One Passphrase, Once per Device

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.


How It's Built

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.


Self-Hosting Guide

One Worker, one D1 database, one R2 bucket — all comfortably inside Cloudflare's free tier.

1. Prerequisites

  • A Cloudflare account
  • Node.js 20+ and npm
  • A GitHub account, if you want push-to-deploy

2. Clone and Install

git clone <your-fork-url>
cd open-beans
npm install

3. Create the D1 Database and R2 Bucket

npx wrangler login
npx wrangler d1 create open-beans-db
npx wrangler r2 bucket create open-beans-images

Copy the database_id that d1 create prints into wrangler.jsonc, then create the tables:

npm run db:migrate:remote

4. Run It Locally

npm run db:migrate:local   # tables in the local D1 emulator
npm run dev                # http://localhost:5173

On 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.

5. Deploy

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.jsonc sets workers_dev: false — the Worker is only reachable through the custom domain you attach next, and deploys can't silently re-enable the workers.dev URL.

6. Add a Custom Domain

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.)

7. Turn On the Login Gate

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.


Migrating from the Flask Version

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; done

Stop 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:

  1. 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.

  2. 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;"
    
  3. 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.


📲 Install It Like an App

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.


Security Model

  • 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_PASSPHRASE secret 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 nosniff and 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).

Development

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 machine

Put AUTH_PASSPHRASE=whatever in .dev.vars (gitignored) to exercise the login gate locally; leave it unset to skip it.


Project Structure

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

License

MIT © Thomas Kleinert — fork it, self-host it, make it yours.


Frequently asked about Open Beans

What is Open Beans?+

Open Beans is a self-hosted Notion alternative built on the Cloudflare developer platform. Keep coffee bags, grinder settings and brew recipes in one private web app.

What does Open Beans replace?+

Open Beans is listed as an alternative to Notion. Compare the features and tradeoffs before migrating.

What Cloudflare primitives does Open Beans use?+

Open Beans is built on D1, R2, Workers.

How much does Open Beans cost to run?+

The reviewed Cloudflare deployment is eligible for Free-plan allowances for the stated small workload and feature scope. Usage limits, CPU, required account setup and separate services apply. Single-household deployment only: configure AUTH_PASSPHRASE before adding data and verify the gate; it is fail-open until configured. Replace the upstream D1 ID, activate R2 and keep small compressed photos within Standard storage/operation quotas. Keep Worker requests/CPU and D1 rows/storage below free limits; all image reads go through the Worker. The default workers_dev:false requires an owned custom domain; alternatively enable workers.dev before deployment to avoid a mandatory domain purchase. No external AI, mail or OAuth provider is needed; the PWA requires connectivity for data. Workers Free dynamic requests are shared across this account (100,000/day), with 10 ms CPU per invocation; workload fit is conditional and has not been measured. D1 Free allowance: 5 million rows read/day, 100,000 rows written/day and 5 GB total storage; unindexed scans and history retention consume quota. R2 Standard allowance: 10 GB-month storage, 1 million Class A and 10 million Class B operations/month; account activation may require billing setup, and other storage classes are excluded. Check current Cloudflare pricing before deploying.

Is Open Beans open source?+

The upstream repository declares the MIT license. Read its terms at https://raw.githubusercontent.com/thkleinert/open-beans/1258aea02bdf8097af9546bd029121cc6144be5d/LICENSE. Source code and contributor credit are available at https://github.com/thkleinert/open-beans.

Discussion · 0

sign in to comment →
No comments yet — be the first.