A self-hosted journal to track beans, roasters, brewing gear, and tasting for coffee sourcing, roasting, and extraction tracking.
BeanQuest tracks the whole loop of a cup of coffee: the roaster that roasted it, the gear used to brew it, and a tasting log tying the two together with notes and a rating.
| Resource | What it tracks |
|---|---|
| Brewing Methods | Brewing mechanism (e.g. V60, espresso machine) and grinder used (e.g. Commandante) |
| Roasting Methods | Roaster (e.g. home-roasting with a drum roaster or a local roaster) |
| Logs | Tasting journal: bean name, process, target roast level, linked roasting + brewing method, roasting notes, grinder setting, 0-5 rating, general notes, date logged |
Data is per-user: sign up or log in with an email and password, and you only see your own methods and logs.
The app is a layered FastAPI service (api.py → application.py → db.py raw SQL over psycopg → Pydantic models) that also serves the built React SPA, so the whole thing runs as one process.
- Backend: FastAPI, Uvicorn, psycopg 3 (connection pool), Pydantic v2, PostgreSQL 17, bcrypt password hashing, PyJWT access tokens
- Frontend: React 19, TypeScript, Vite 8, MUI v9
- Packaging: Poetry (backend), npm (frontend), single multi-stage distroless Docker image
Pre-built images are published to GHCR on tagged releases:
docker pull ghcr.io/hanpeter/beanquest:latestOr build the image locally:
docker build -f docker/beanquest/Dockerfile -t beanquest .Prerequisites: Docker (for Postgres), Poetry, and Node lts/krypton (see .nvmrc).
poetry install
cd frontend && npm ciThe container needs DATABASE_URL and JWT_SECRET (see Configuration) and listens on port 8000:
docker run --rm -p 8000:8000 \
-e DATABASE_URL="postgresql://beanquest:[email protected]:5432/beanquest" \
-e JWT_SECRET="$(openssl rand -hex 32)" \
beanquest-
Start Postgres 17:
docker run -d --name beanquest-pg \ -e POSTGRES_USER=beanquest -e POSTGRES_PASSWORD=beanquest -e POSTGRES_DB=beanquest \ -p 5432:5432 -v beanquest-pgdata:/var/lib/postgresql/data postgres:17
-
Apply the migrations (see Database migrations):
docker run --rm --network container:beanquest-pg -v "$PWD/db:/db" \ -e DATABASE_URL="postgres://beanquest:beanquest@localhost:5432/beanquest?sslmode=disable" \ ghcr.io/amacneil/dbmate:2.36.0 --no-dump-schema migrate
-
Build the frontend (outputs into
beanquest/static/, which the API serves):cd frontend && npm ci && npm run build
-
Run the API:
DATABASE_URL="postgresql://beanquest:beanquest@localhost:5432/beanquest" \ JWT_SECRET="$(openssl rand -hex 32)" \ poetry run uvicorn beanquest.api:app --reload
-
Open
http://127.0.0.1:8000.
For frontend-only iteration, run npm run dev in frontend/ instead of step 3. Vite dev-serves the SPA and proxies /api requests to http://localhost:8000.
Migrations live in db/migrations/ and are applied with dbmate. Each file has a -- migrate:up and a -- migrate:down section, and runs in one transaction. Applied versions are recorded in the schema_migrations table.
Create a migration with the same image used in step 2, swapping migrate for new <name>:
docker run --rm -v "$PWD/db:/db" ghcr.io/amacneil/dbmate:2.36.0 new add_thing_tableCREATE INDEX CONCURRENTLYcan't run in a transaction. Start that file with-- migrate:up transaction:false.- The six baseline migrations mirror the original schema and refuse to roll back. Write a real
-- migrate:downfor new ones. - dbmate takes no lock, so run it from one place at a time. Never run it at app startup.
On a release tag, CI builds a separate beanquest-migrator image (dbmate and the migrations only). A one-off Fly machine runs migrate with the app's DATABASE_URL secret before the app deploys, and a failed migration stops the deploy. The app image contains neither dbmate nor the migrations. The URL needs sslmode=disable if the database server has SSL off.
| Variable | Required | Description |
|---|---|---|
DATABASE_URL |
Yes | Postgres connection string, e.g. postgresql://beanquest:beanquest@localhost:5432/beanquest. The app fails to start without it. |
JWT_SECRET |
Yes | Secret used to sign access tokens. The app fails to start without it. Changing it invalidates all issued tokens. |
ACCESS_TOKEN_TTL_SECONDS |
No | Access token lifetime in seconds. Defaults to 14400 (4 hours). |
Host and port are not read from the environment: they're set on the uvicorn command line (--host, --port).
All resources live under /api/v1. Every resource route requires an Authorization: Bearer <token> header (a missing, invalid, or expired token returns 401) and only returns or modifies the caller's own records. They follow the same CRUD shape:
brewing-methodsroasting-methodspast-logs
Each supports GET (list), POST (create, 201), GET /{id}, PUT /{id}, and DELETE /{id} (204). A missing {id} returns 404; deleting a roasting or brewing method still referenced by a log returns 409 (the schema uses ON DELETE RESTRICT).
TOKEN=$(curl -s -X POST http://127.0.0.1:8000/api/v1/auth/login \
-H 'Content-Type: application/json' \
-d '{"email": "[email protected]", "password": "your-password"}' | jq -r .access_token)
curl -H "Authorization: Bearer $TOKEN" http://127.0.0.1:8000/api/v1/brewing-methods
curl -X POST http://127.0.0.1:8000/api/v1/roasting-methods \
-H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' \
-d '{"roaster_name": "Sey Coffee", "description": "Brooklyn-based"}'Under /api/v1/auth:
| Endpoint | Description |
|---|---|
POST /lookup |
Body {"email"}. Returns {"exists": bool}; the frontend uses it to choose between login and signup. |
POST /signup |
Body {"first_name", "last_name", "email", "password"}. Returns 201 and an access token. Passwords need 10+ characters and at least 2 of: lowercase, uppercase, number, special character. |
POST /login |
Body {"email", "password"}. Returns an access token. A wrong password returns 401 with attempts_left; after 5 failures the account is locked for 5 minutes and returns 429 with a Retry-After header. |
GET /me |
Returns the authenticated user. |
Emails are trimmed and lowercased. Tokens are returned as {"access_token", "token_type": "bearer"}. There is no password-change or profile-update endpoint yet.
Contributions are welcome! Please feel free to submit a Pull Request. CI (test.yml) runs the same checks on every PR:
# backend
poetry run pycodestyle .
poetry run pytest
# frontend
cd frontend
npx tsc -b
npm run test:coverageBackend coverage is gated at 95% (setup.cfg); frontend coverage is gated at 95% on src/logic/** (vite.config.ts). CI (build.yml) also builds the Docker image on every PR and, on tagged releases, publishes it to GHCR.