Local-first, open source, free, minimalist recipe and grocery list manager
See the codeA local-first, open source, minimalist recipe and grocery list manager.
Recipes are plain text in Cooklang (@flour{2%cups},
#pan{}, ~{10%minutes}), so ingredients fall out of the recipe for free:
add a recipe to the shopping list, or collect recipes onto a menu and see
which ones the list already covers. Everything works offline and syncs
between devices when it can. An MCP endpoint lets an AI assistant read and
edit your recipes and lists on your behalf.
Live at plaintextpantry.com.
| Recipes | A recipe | Shopping list | Menu | Settings |
|---|---|---|---|---|
![]() | ![]() | ![]() | ![]() | ![]() |
Taken at iPhone SE size by the run in e2e/: the real client against a
mock backend, so the household is invented but nothing about the app is.
./e2e/run.sh retakes them, and needs only Docker.
Everything is F#, front to back, with one Cooklang parser shared by both.
| Client | Fable → JavaScript, Elmish + Feliz (React 19), Tailwind v4, Vite, CodeMirror 6 for the recipe editor |
| Local data | PowerSync — a SQLite database in the browser (wa-sqlite) that syncs with Postgres. The UI reads live queries and writes locally; sync happens behind it |
| Server | ASP.NET Core 10 + Giraffe. Signs users in, applies PowerSync's upload queue to Postgres, mints sync tokens, and serves the MCP tools |
| Shared | app/src/Shared: the Cooklang parser, JSON codecs (Thoth), and the small bits of domain logic; compiled once for .NET and once by Fable |
| Identity | Keycloak, brokering Google sign-in; also the OAuth server for MCP clients |
| Infra | Postgres 16, Caddy (auto-HTTPS, static files), Docker Compose, Terraform, one arm64 EC2 box |
Layout:
app/src/Client Fable app (App.fs is the whole UI; Db.fs the data layer)
app/src/Server API, auth, MCP tools, Postgres access
app/src/Shared Cooklang parser + types shared by both
app/tests xunit tests for Shared (incl. the Cooklang canonical suite)
infra/docker Local compose stack, Postgres schema + migrations, PowerSync sync rules, Dockerfiles
infra/keycloak Realm provisioning and the login theme
infra/terraform AWS resources
infra/deploy Production compose stack and the script that applies it on the box
e2e Screenshot run: the real client against a mock backend, in Docker
user-activity.sh Who has signed in to production, and how often
Everything belongs to a pantry: a household with an owner, a name and a
list of members, carried on every row as pantry_id. You get one of your own
the first time you sign in, and join someone else's by scanning the QR code on
their settings page — which asks to be let in and waits for the owner to
approve it. Inside a pantry every member may do anything, so the upload path
checks membership rather than ownership (decide in Server/Db.fs) and the
sync rules hand a device the rows of the pantries it is approved in.
You need Docker, the .NET 10 SDK and Node.
./dev.sh
That starts Postgres, PowerSync and Keycloak in Docker (applying
infra/docker/postgres/migrate.sql and provisioning the realm), then the F#
server under dotnet watch and the client under Fable + Vite, both
reloading on save. Open http://localhost:5173 and press Login with Google
— locally that signs you in as the seeded dev@localhost user without
leaving the app.
./dev.sh down stops the containers; ./dev.sh reset also wipes their data.cd app && dotnet test tests/Shared.Tests../e2e/run.sh — needs Docker and none of the above running.http://localhost:5050/mcp and works with the dev user.Schema changes go in two places: infra/docker/postgres/init/01-init.sh
(fresh databases) and migrate.sql (re-runnable, applied on every start).
New tables also need a pantry_id, a stream in
infra/docker/powersync/sync-config.yaml, a column list in the client's
Db.fs, and an entry in the server's upload whitelist (Server/Db.fs).
Production is a single t4g.small running the compose stack in
infra/deploy; the full story, including one-time setup (Terraform
bootstrap, domain delegation, the Google OAuth client), lives in
infra/terraform/README.md.
Day to day, merging to main deploys. The
workflow has three stages, each of which
skips itself when nothing relevant changed:
terraform plan; applies only if the plan is non-empty.server and web images on the runner, tagged by
a hash of app/ and the Dockerfiles, and pushes to ECR unless that tag
already exists.infra/deploy to the box over SSM and runs
deploy.sh, which pulls images, runs the migration, re-provisions the
Keycloak realm and does docker compose up -d (only containers whose
image or config changed are recreated).To redeploy without a code change: gh workflow run deploy.yml. There is
no SSH; for a shell on the box use
aws ssm start-session --target <instance id>, then cd /opt/plaintextpantry
and docker compose logs -f server. Secrets live in SSM Parameter Store
under /plaintextpantry/*.
Two questions, two places, because they want different things: how much the app is being used, and who is using it.
How much is a Grafana dashboard on the observability box this project
shares with fastbreak and topspin —
https://fastbreak-o11y.fly.dev/grafana, dashboard Plaintext Pantry. The
server writes the counters behind it (Usage.fs):
pages opened, sign-ins, recipes created, pantries shared, and the MCP calls
that changed something. Totals only — no user id, no session, nothing that
says which of them was whom — and no key in Parameter Store means no
counting at all. The dashboard itself is defined by
o11y/grafana/plaintextpantry-dashboard.py in the fastbreak repository; edit
there and re-run it.
Who is Keycloak's, since it is the only thing that sees a sign-in as a
person. It keeps login events for 90 days (turned on by provision.sh), and
this prints them, a column of addresses and a column of counts:
./user-activity.sh # every sign-in there is a record of
./user-activity.sh 7 # or just the last week
who | logins | days
---------------+--------+------
sam@gmail.com | 2 | 31
joe@gmail.com | 1 | 4
logins is sign-ins; days is days the app was opened, which is the one to
read for someone who signed in once in March and has been cooking from it ever
since — the app renews its session against Keycloak once a day, so a day of
use leaves a mark without a login.
user-activity.sh runs infra/deploy/user-activity.sh
on the box through SSM Run Command, which needs no SSH and no
session-manager plugin. Away from a checkout, that is:
C=$(aws ssm send-command --targets Key=tag:Name,Values=plaintextpantry --document-name AWS-RunShellScript --parameters 'commands=["/opt/plaintextpantry/user-activity.sh"]' --query Command.CommandId --output text) && sleep 6 && aws ssm list-command-invocations --command-id "$C" --details --query 'CommandInvocations[0].CommandPlugins[0].Output' --output text
Both only know about sign-ins since events were switched on, so the record starts at the deploy that shipped them.
F#
77.6%
Shell
7.2%
JavaScript
5.2%
HCL
3.8%
CSS
3.0%
PLpgSQL
1.6%
Local-first, open source, free, minimalist recipe and grocery list manager
See the codeA local-first, open source, minimalist recipe and grocery list manager.
Recipes are plain text in Cooklang (@flour{2%cups},
#pan{}, ~{10%minutes}), so ingredients fall out of the recipe for free:
add a recipe to the shopping list, or collect recipes onto a menu and see
which ones the list already covers. Everything works offline and syncs
between devices when it can. An MCP endpoint lets an AI assistant read and
edit your recipes and lists on your behalf.
Live at plaintextpantry.com.
| Recipes | A recipe | Shopping list | Menu | Settings |
|---|---|---|---|---|
![]() | ![]() | ![]() | ![]() | ![]() |
Taken at iPhone SE size by the run in e2e/: the real client against a
mock backend, so the household is invented but nothing about the app is.
./e2e/run.sh retakes them, and needs only Docker.
Everything is F#, front to back, with one Cooklang parser shared by both.
| Client | Fable → JavaScript, Elmish + Feliz (React 19), Tailwind v4, Vite, CodeMirror 6 for the recipe editor |
| Local data | PowerSync — a SQLite database in the browser (wa-sqlite) that syncs with Postgres. The UI reads live queries and writes locally; sync happens behind it |
| Server | ASP.NET Core 10 + Giraffe. Signs users in, applies PowerSync's upload queue to Postgres, mints sync tokens, and serves the MCP tools |
| Shared | app/src/Shared: the Cooklang parser, JSON codecs (Thoth), and the small bits of domain logic; compiled once for .NET and once by Fable |
| Identity | Keycloak, brokering Google sign-in; also the OAuth server for MCP clients |
| Infra | Postgres 16, Caddy (auto-HTTPS, static files), Docker Compose, Terraform, one arm64 EC2 box |
Layout:
app/src/Client Fable app (App.fs is the whole UI; Db.fs the data layer)
app/src/Server API, auth, MCP tools, Postgres access
app/src/Shared Cooklang parser + types shared by both
app/tests xunit tests for Shared (incl. the Cooklang canonical suite)
infra/docker Local compose stack, Postgres schema + migrations, PowerSync sync rules, Dockerfiles
infra/keycloak Realm provisioning and the login theme
infra/terraform AWS resources
infra/deploy Production compose stack and the script that applies it on the box
e2e Screenshot run: the real client against a mock backend, in Docker
user-activity.sh Who has signed in to production, and how often
Everything belongs to a pantry: a household with an owner, a name and a
list of members, carried on every row as pantry_id. You get one of your own
the first time you sign in, and join someone else's by scanning the QR code on
their settings page — which asks to be let in and waits for the owner to
approve it. Inside a pantry every member may do anything, so the upload path
checks membership rather than ownership (decide in Server/Db.fs) and the
sync rules hand a device the rows of the pantries it is approved in.
You need Docker, the .NET 10 SDK and Node.
./dev.sh
That starts Postgres, PowerSync and Keycloak in Docker (applying
infra/docker/postgres/migrate.sql and provisioning the realm), then the F#
server under dotnet watch and the client under Fable + Vite, both
reloading on save. Open http://localhost:5173 and press Login with Google
— locally that signs you in as the seeded dev@localhost user without
leaving the app.
./dev.sh down stops the containers; ./dev.sh reset also wipes their data.cd app && dotnet test tests/Shared.Tests../e2e/run.sh — needs Docker and none of the above running.http://localhost:5050/mcp and works with the dev user.Schema changes go in two places: infra/docker/postgres/init/01-init.sh
(fresh databases) and migrate.sql (re-runnable, applied on every start).
New tables also need a pantry_id, a stream in
infra/docker/powersync/sync-config.yaml, a column list in the client's
Db.fs, and an entry in the server's upload whitelist (Server/Db.fs).
Production is a single t4g.small running the compose stack in
infra/deploy; the full story, including one-time setup (Terraform
bootstrap, domain delegation, the Google OAuth client), lives in
infra/terraform/README.md.
Day to day, merging to main deploys. The
workflow has three stages, each of which
skips itself when nothing relevant changed:
terraform plan; applies only if the plan is non-empty.server and web images on the runner, tagged by
a hash of app/ and the Dockerfiles, and pushes to ECR unless that tag
already exists.infra/deploy to the box over SSM and runs
deploy.sh, which pulls images, runs the migration, re-provisions the
Keycloak realm and does docker compose up -d (only containers whose
image or config changed are recreated).To redeploy without a code change: gh workflow run deploy.yml. There is
no SSH; for a shell on the box use
aws ssm start-session --target <instance id>, then cd /opt/plaintextpantry
and docker compose logs -f server. Secrets live in SSM Parameter Store
under /plaintextpantry/*.
Two questions, two places, because they want different things: how much the app is being used, and who is using it.
How much is a Grafana dashboard on the observability box this project
shares with fastbreak and topspin —
https://fastbreak-o11y.fly.dev/grafana, dashboard Plaintext Pantry. The
server writes the counters behind it (Usage.fs):
pages opened, sign-ins, recipes created, pantries shared, and the MCP calls
that changed something. Totals only — no user id, no session, nothing that
says which of them was whom — and no key in Parameter Store means no
counting at all. The dashboard itself is defined by
o11y/grafana/plaintextpantry-dashboard.py in the fastbreak repository; edit
there and re-run it.
Who is Keycloak's, since it is the only thing that sees a sign-in as a
person. It keeps login events for 90 days (turned on by provision.sh), and
this prints them, a column of addresses and a column of counts:
./user-activity.sh # every sign-in there is a record of
./user-activity.sh 7 # or just the last week
who | logins | days
---------------+--------+------
sam@gmail.com | 2 | 31
joe@gmail.com | 1 | 4
logins is sign-ins; days is days the app was opened, which is the one to
read for someone who signed in once in March and has been cooking from it ever
since — the app renews its session against Keycloak once a day, so a day of
use leaves a mark without a login.
user-activity.sh runs infra/deploy/user-activity.sh
on the box through SSM Run Command, which needs no SSH and no
session-manager plugin. Away from a checkout, that is:
C=$(aws ssm send-command --targets Key=tag:Name,Values=plaintextpantry --document-name AWS-RunShellScript --parameters 'commands=["/opt/plaintextpantry/user-activity.sh"]' --query Command.CommandId --output text) && sleep 6 && aws ssm list-command-invocations --command-id "$C" --details --query 'CommandInvocations[0].CommandPlugins[0].Output' --output text
Both only know about sign-ins since events were switched on, so the record starts at the deploy that shipped them.
F#
77.6%
Shell
7.2%
JavaScript
5.2%
HCL
3.8%
CSS
3.0%
PLpgSQL
1.6%