Files
foodster/README.md
T
Esa Kataja 2974ffc621
check / check (push) Successful in 2m14s
feat!: drop the built-in auth in favour of Authelia
BREAKING CHANGE: PASSWORD is gone and AUTH and CERTRESOLVER are required.
The server's compose.yaml and .env must be updated in the same deploy — the
new image ignores PASSWORD, and the old one refuses to start without it.

Authelia now sits in front of Traefik, so the app was asking for a second
password at the same door. Two prompts, and the weaker of the two was the one
holding a single shared secret with no sessions, no MFA and no revocation.
Deleting it is the whole change: Authelia already does this properly, once,
for every service on the host.

Gone: auth(), challenge(), the whole of throttle.go and its tests, and
golang.org/x/time with them. routes() returns the bare mux, /healthz is an
ordinary route on it, and the smoke script drops sixty -u flags. Roughly 230
lines removed and nothing written to replace them.

What holds the app up now, both asserted in compose.yaml:

- The router names the Authelia middleware through AUTH. Traefik takes a
  router out of service when its middleware does not resolve, so a typo or an
  unset variable fails shut rather than serving the app open.
- The container still publishes no ports, so the proxy is the only thing that
  can reach it. Publishing 8080 would now bypass authentication outright, not
  merely TLS — the comment there says so.

certresolver replaces the bare tls=true, parameterised as CERTRESOLVER: the
server had been carrying that label by hand since the first deploy. Naming a
resolver implies tls=true, so it stays one label.

TestAuth and TestHealthzSkipsAuth are replaced by one test asserting every
route answers without credentials — a 401 from here would now mean auth had
crept back in.
2026-09-06 13:31:17 +03:00

11 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 use. The meal catalog and the daily log came first, because the suggester is worthless until there are a few weeks of real history to weight against.

Working:

  • Kirjaa — log a dinner: pick a dish, tick sides, save. Dishes are grouped by category, then ordered and sized by how often they are eaten, so the likely answer is the biggest target. The history sits on the same page underneath: every day back to the first entry, unlogged days shown as explicit gaps, and every row opening that day's logger in place. Older days arrive a window at a time.
  • Ruuat — add, edit and delete mains and sides, or import a whole bundle by paste or file upload. Grouped by category and alphabetical inside, since this is a list you manage rather than one you pick from. Edit and delete are row icons, and a delete asks first. Deletes are soft, so old log entries keep showing the dish they used.
  • Search as you type on both tabs, debounced, patching just the list.
  • Category icons, not colour dots: shape and colour together, so two marks are told apart by more than hue.
  • Light / dark, remembered per device, dark by default. The button shows the theme that is on — moon while dark, sun while light — not the one a click would bring.

Nothing navigates. Every interaction patches the page through Datastar, so the scroll position survives; links and forms still work with JavaScript off.

Still to build:

  • Stage 2: the seven-meal suggester, which starts once there is history to weight against.

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 none in-app — Authelia, via a Traefik forward-auth middleware
Runtime image FROM scratch

Working on it: CONTRIBUTING.md — branches, commit messages, and the conventions that are easy to miss.

Branches

main holds released versions only. Every release tag points at a commit on main, so its history is the deployment history. Nothing is committed to main directly — it moves only by fast-forwarding dev into it.

All development happens on dev. main is protected on the remote: it takes no direct pushes, so a release arrives through a pull request.

git switch dev                              # where the work happens
# ... commits ...
make check                                  # lint, unit tests, smoke
git push origin dev                         # CI runs make check too

tea pr create --base main --head dev        # or open it in the forge
# squash-merge the pull request — that is the whole release

Merging is the release. CI builds the image, tags it vYYYYMMDD-N and latest, pushes both to the registry, and creates the matching git tag. There is nothing to run locally afterwards; pull the new image on the server when you are ready.

A release tag can therefore never point at a commit that was not released: the workflow only runs on main, and main only moves through a pull request.

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 smoke      end-to-end check against a scratch server
make check      lint + test + smoke — run before every commit
make build      ./foodster
make up/down/logs   compose

Images are built by CI, not here — see Deployment.

Occasional commands

Rare enough not to earn a make target. Both write into cmd/foodster/static/, and the results are committed.

Re-download the vendored Datastar client after bumping the version:

curl -sSfL -o cmd/foodster/static/datastar.js \
  "https://cdn.jsdelivr.net/gh/starfederation/[email protected]/bundles/datastar.js"

Re-rasterise the home-screen icons after editing assets/icon.svg:

cd cmd/foodster/static
rsvg-convert -w 180 -h 180 ../../../assets/icon.svg -o apple-touch-icon.png
rsvg-convert -w 192 -h 192 ../../../assets/icon.svg -o icon-192.png
rsvg-convert -w 512 -h 512 ../../../assets/icon.svg -o icon-512.png
oxipng -o max --zopfli --quiet apple-touch-icon.png icon-192.png icon-512.png

oxipng -o max on its own loses to optipng on the 512; --zopfli wins at every size. Slow, but these are three tiny files built by hand.

Importing dishes

The Ruuat 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:

go run ./cmd/foodster -import seeds/testi.json

Icons

cmd/foodster/static/favicon.svg is the browser-tab icon and follows the system theme. The home-screen icons are PNGs, because a home-screen icon cannot be transparent and must not change with the theme; they are rasterised from assets/icon.svg, which is opaque and keeps the artwork inside the central 80% so Android can mask it to any shape.

The PNGs are committed so the build needs no rasterizer. The commands to regenerate them are under Occasional commands.

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
DB ./data/foodster.db SQLite file path; the directory is created if missing.
ENV prod Anything else is prefixed to the tab title (dev · Foodster).
ADDR :8080 Listen address. Only useful for a second local instance.
PUID / PGID 1000 Host owner of ./data, for the bind mount.
TZ Europe/Helsinki Used for every calendar-day calculation.
REPO required to run Image repository, no tag. Used by compose.yaml.
TAG latest Tag to run under compose.
HOST required to run Hostname Traefik routes to.
AUTH required to run Traefik middleware that authenticates the app, e.g. authelia@docker.
CERTRESOLVER required to run Traefik certificate resolver for HOST.

Names carry no prefix: the container gives them their own namespace already. PUID/PGID are the exception — UID is read-only in bash, so a value set in .env would be silently replaced by the invoking shell's own.

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 by CI when a pull request merges into main, and run under Docker Compose on a LAN server. They are OCI images, so either engine works.

# on the server, once CI reports the build finished:
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 you can inspect the file with any sqlite client without going through the engine. Back it up with

sqlite3 data/foodster.db ".backup data/foodster-$(date +%F).db"

rather than copying the directory: the database runs in WAL mode, and a plain copy of a live database can catch the .db and its -wal mid-write.

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

If the app exits with cannot open /data/foodster.db ... unable to open database file (14), the ownership does not match. Docker creates a missing bind-mount directory as root, and the container is not root:

ls -ldn data                      # whose is it?
sudo chown -R 1000:1000 data      # match PUID / PGID
docker compose restart

Security

The app has no authentication of its own. It trusts every request it receives, because the only thing that can reach it is Traefik, and Traefik hands each request to Authelia first. Access control, sessions, brute-force protection and multi-factor all live there, where they are configured once for every service on the host instead of reimplemented per app.

Two things make that safe, and both must hold:

  • AUTH names the Authelia middleware on the router. It is the whole of the app's access control. Traefik takes a router out of service when its middleware does not resolve, so a typo fails shut rather than open.
  • The container publishes no ports. It is reachable only over the shared traefik network. Publishing 8080 would put an unauthenticated, unencrypted copy of the app on the host and defeat both of the above.

/healthz returns nothing but the version, so it is safe to bypass in Authelia if a monitor needs to poll it from outside.

Earlier versions carried HTTP Basic auth and a per-IP guess limiter. Both were removed once Authelia was in front: two prompts for one door, and the weaker of the two was the one holding a shared password.

Mockups

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

License

MIT.