Files
foodster/README.md
T
Esa Kataja c6f44bb427 Keep the database in ./data and bind-mount it
SQLite writes three files — the database plus its -wal and -shm companions.
Putting them in one directory means a deployment mounts a single path and a
backup copies a single directory.

- FOODSTER_DB defaults to ./data/foodster.db, and openDB creates the parent
  directory on startup rather than failing on a fresh checkout
- compose bind-mounts ./data instead of using a named volume, so the file can
  be listed, copied and opened with any sqlite client without going through
  the container engine
- the image runs as UID 65534, which cannot write to a host directory owned
  by someone else, so compose now sets user: from FOODSTER_UID/FOODSTER_GID
- `make up` creates ./data first: left to the engine it appears root-owned
  and the app silently cannot write to it
2026-09-05 18:17:14 +03:00

5.4 KiB

Foodster

A self-hosted dinner log and meal suggester for a single household. Record what the family actually ate, and — later — let the app propose seven dinners drawn from that history.

The interface is in Finnish. The code, comments and documentation are in English.

See PRD.md for the full specification.

Status

Stage 1 — eating history: in development. The meal catalog and the daily log come first, because the suggester is worthless until there are a few weeks of real history to weight against.

Stage 2 — the seven-meal suggester — starts once that history exists.

Stack

One static Go binary. No Node.js, no bundler, no separate database server.

Language Go 1.27
HTTP stdlib net/http + http.ServeMux
Views templ, server-rendered
Interactivity Datastar — signals and DOM patching in one ~11 kB script
Styling hand-written CSS, light-dark() for themes
Database SQLite via modernc.org/sqlite (pure Go)
Auth HTTP Basic, one shared household password
Runtime image FROM scratch

Quick start

cp .env.example .env     # then edit it
make run                 # http://localhost:8080

make on its own lists every target:

make fix        gofmt, templ fmt, go mod tidy
make lint       go vet, gofmt check, golangci-lint when installed
make test       go test ./...
make build      ./foodster
make seed       import a dish bundle (SEED=seeds/testi.json)
make vendor     re-download the Datastar client
make image      build and tag vYYYYMMDD-N (creates a git tag)
make push       push the newest tag and :latest
make release    image + push
make up/down/logs   compose

Importing dishes

The Ruoat tab takes a bundle of mains and sides: paste the JSON or upload a file, and the app reports row by row what it did.

{
  "mains": [{"name": "Kanacurry", "categories": ["chicken"], "has_sides": true}],
  "sides": [{"name": "Riisi"}]
}

Categories are meat, chicken, fish and vegetarian — stored in English even though the UI is Finnish. A dish may list several, which is how build-your-own meals like tortillas cover every category at once. has_sides defaults to true when omitted.

Import is best-effort, never atomic. Valid rows land; invalid ones are skipped and named (tuntematon kategoria, ei kategorioita, jo listalla).

Names are normalized to sentence case on the way in — whitespace collapses and the first letter is capitalized, the rest is left as typed, because Finnish capitalizes only the first word of a phrase. keitetyt perunat is stored as Keitetyt perunat, while BBQ-kylkeä and Kotipizza keep their capitals. Duplicates are caught case-insensitively, so re-importing a bundle is safe.

The same importer runs from the command line when you just want to repopulate a scratch database:

make seed                      # or: SEED=seeds/other.json make seed

Migrations

Numbered SQL files in cmd/foodster/migrations, embedded in the binary and applied in filename order at startup. Each runs in a transaction and is recorded in schema_migrations, so it applies exactly once.

Adding one means dropping a new NNNN_what_it_does.sql into that directory. Never edit a migration that has already shipped — a released file has run on a live database and will not run again.

Configuration

Everything is environment variables. .env is gitignored; start from .env.example.

Variable Default Purpose
FOODSTER_PASSWORD required Shared password. The app will not start without it.
FOODSTER_DB ./data/foodster.db SQLite file path; the directory is created if missing.
FOODSTER_UID / FOODSTER_GID 1000 Host owner of ./data, for the bind mount.
TZ Europe/Helsinki Used for every calendar-day calculation.
FOODSTER_REPO required to build Image repository, no tag.
FOODSTER_TAG latest Tag to run under compose.
FOODSTER_PORT 8080 Host port to publish.

Set TZ in development too. Under UTC the date rolls over three hours late, which is exactly when dinner gets logged.

Deployment

Images are built with Podman and run under Docker Compose on a LAN server. They are OCI images, so either engine works.

make release             # build, tag, push
# on the server:
docker compose pull && docker compose up -d

Versions are CalVer — vYYYYMMDD-N, where N is the Nth build that day. The running version is served at GET /healthz, which is the one route outside authentication.

There is no database container. SQLite lives in ./data, bind-mounted into the container, so a backup is cp -r data and you can inspect the file with any sqlite client without going through the engine.

That directory must exist and be owned by the user compose runs as — make up creates it, and FOODSTER_UID/FOODSTER_GID in .env tell the container who that is. Get them from id -u and id -g.

Security

Access is a single shared password over HTTP Basic — no accounts, no sessions. Credentials are compared in constant time, but Basic auth sends them in cleartext, so this belongs on a private LAN. Put TLS in front of it before exposing it anywhere else.

Mockups

mockups/ holds standalone HTML design studies. Open them directly in a browser; they are references, not part of the build.

License

MIT.