Privacy-first, self-hosted food, calorie and nutrition tracking for a private server. No ads, no analytics, no subscription. Nutrition data carries explicit provenance, and a value that is unknown stays unknown rather than becoming zero.
NutriCore is a TypeScript modular monolith: Next.js serves the responsive PWA and the API, Prisma owns the PostgreSQL schema, and provider modules isolate external data and AI behind adapters.
Website: macnite.github.io/NutriCore
— feature overview, the application's own interface running on static data, and
a deep dive on setup, the codebase and deployment. Its source is in website/,
and it is published by
.github/workflows/website.yml.
Implemented and covered by tests:
- Accounts — local email + password, Argon2id hashing, opaque session tokens stored only as SHA-256 hashes, HTTP-only cookies, logout, account deletion. No external identity provider.
- Onboarding and profile — display name, language, date of birth, height, weight, biological sex, activity level and goal.
- Calorie target — Mifflin-St Jeor. Every component (BMR, activity multiplier, TDEE, goal adjustment, calculated target, manual override) is stored and displayed, never just the final number.
- Today — the day view, and the application's start page: breakfast, lunch,
dinner and snacks, add, edit, remove, copy the previous day, day navigation,
per-meal and per-day totals, an energy summary and a micronutrient panel with
coverage. Navigation is Today, Foods and Progress, with Settings behind the
account button;
/diaryremains only as a date-preserving redirect. - Nutrition snapshots — every entry freezes its nutrition at logging time, so a later provider update cannot rewrite history.
- Food search — local-first pipeline (barcode → exact → favourites → recent/frequent → custom foods → cached external → fuzzy → remote), debounced, with deterministic ranking and visible source badges.
- Barcode scanning in the browser — from the food search field and the
recipe form. The camera is decoded on the device (
@zxing/browser, loaded only when the scanner is opened) and the result is looked up exactly like a typed barcode. Manual entry still works; a live camera needs an HTTPS origin. - Bundled food databases — BLS 4.0 and USDA FoodData Central (Foundation and SR Legacy) ship with the application, are imported into PostgreSQL, and answer without a network request. See Food sources.
- Open Food Facts — barcode lookup and free-text search, local caching, full provenance, graceful degradation when unreachable.
- Custom foods — user-created foods with an explicit basis, servings and optional density. Empty fields stay unknown.
- Recipes — create and edit recipes from existing foods, inspect nutrition per serving and per 100 g with coverage, and log immutable recipe snapshots.
- Sharing recipes with the instance — opt-in: publish one of your recipes for the other members of this installation, browse what they have shared, and save a shared recipe as your own independent copy. See Sharing recipes.
- Reporting wrong nutrition — any member can flag a food from the shared catalogue and propose the values it should carry. An administrator sees what the food says beside what was reported, applies the numbers, revises them, or refuses the report with a note the reporter reads on the food. See Reporting wrong nutrition.
- Sport and activity — a per-day activity log with 21 activities and their intensity variants. Active calories come from the MET value of the 2024 Adult Compendium of Physical Activities, snapshotted per entry together with its compendium code and the body weight the estimate used, so an old entry never silently changes. Adding them to the day's calorie target is a per-user switch (on by default) in Settings.
- Weight and body measurements — one check-in records weight, notes, body circumferences and, where a scale supplies them, body composition. The measurement chart plots any of them together, with a 7-day moving average and the goal line from the profile's target weight, plus an accessible text summary and table.
- Body progress — a four-axis composition diamond and a schematic body figure drawn from the recorded circumferences, each with per-value provenance distinguishing a typed number from an accepted optical estimate. Either visualisation can be switched off on its own.
- Body scanning — opt-in two-view photo capture that estimates circumferences on CPU, reviewed by hand before anything is recorded. See Body scanning.
- Progress charts — daily achievement of the calorie, macro and micronutrient targets over time, and recorded active calories by day.
- Calories per meal — optional, off by default: each meal's share of the
day's allowance shown beside what it holds (
777 / 498 kcal), with the split chosen per user from four presets or typed as four percentages with a live kcal preview. See Calories per meal. - Settings — profile, target override, language, theme, whether activity calories count towards the target, how the day's calories are divided over the meals, the AI, web-research and AI-approval switches, which body visualisations are drawn, exports, invitations where SMTP is configured, and account deletion. Secrets are never displayed.
- Administration — for the
ADMINrole only: invite or batch-invite users with single-use links, activate and deactivate accounts, configure SMTP, watch the AI job queue with its retries and errors, import the bundled food databases, run and export the nutrition backfill, decide the nutrition members have reported as wrong, and check service reachability (diagnostics). Reachable from Settings → Administration. - Export — versioned JSON (format version 3) of profile, targets, weights, body measurements, body-scan estimates and decisions, favourites, foods, recipes, what you have published, diary days and research runs, plus diary and weight CSV. Credentials are excluded, and so are the captured scan images, which are deleted minutes after a scan runs. Activity entries are the one personal record the JSON envelope does not carry yet.
- German and English throughout, with locale-correct number formatting
(
1.234,5 kcal/1,234.5 kcal). - Light / dark / system themes, responsive layout with bottom navigation on mobile, PWA manifest.
AI, all of it asynchronous. AI_ENABLED and the per-user switch both default
to on, but nothing is ever called until a worker picks the job up and reaches
the configured Ollama; setting either to off means no request leaves the server
for AI purposes. See
Asynchronous AI worker, Ollama, and SearXNG:
- Quick meal — free text, a photo, a public recipe URL, or any combination. The model decomposes the sentence, each component is resolved against real foods, and what is logged carries its provenance. See How a quick meal becomes diary entries.
- Recipe creation from a link, an image or free text — stored as a
DRAFTrecipe that cannot be logged until it is confirmed. - AI food search — when a search finds nothing, a local Ollama model reconstructs the dish, ingredients are matched against the database, and the result is reviewed and confirmed before anything is stored. See AI food search.
- Nutrition backfill — fills gaps in the food catalogue from a source page,
marks every value it writes as
AI_ENRICHMENT, waits for review by whoever owns the food, and can export the approved values for contribution back. - Web research — optional, off by default: an AI run may be given source URLs, fetched through an SSRF guard and sanitised before the model sees them.
src/app/ App Router pages, server actions and route handlers
src/components/ Shared UI
src/server/ Session, authorisation and domain services
src/lib/ Pure domain logic (nutrition, calories, units, ranking, …)
src/providers/ External adapters and the food source registry
(Open Food Facts, USDA FoodData Central, FatSecret, Ollama,
the body-scan estimator)
src/middleware.ts The security headers and the password-change gate
src/worker.ts The second process: the AI queue, the sweepers, dataset import
src/i18n/ Locale resolution
messages/ de.json / en.json translation catalogues
prisma/ Schema, migrations and the optional development seed
datasets/raw/ Upstream food-database downloads (a build input, not shipped)
datasets/bundled/ The converted, versioned artifacts that do ship
scripts/ Dataset conversion and import, the enrichment export, the
standalone start-up wrapper
docker/ Entrypoint, healthcheck and the migration runner
e2e/ Playwright end-to-end specs
tests/ Authorisation, security, retention, migration-replay and i18n
integration tests
website/ The zero-dependency generator for the public site
Route handlers and server actions resolve the session and authorise the tenant before calling a service. Provider responses and AI output are validated with Zod at the boundary. Public provider foods have no owner; every personal record has a user relation. See docs/ARCHITECTURE.md.
- Docker 25+ with Compose v2 (production), or
- Node.js 22+ and PostgreSQL 16+ (development)
The images are built on Node 22 (Alpine) and the compose stack runs PostgreSQL 17.6, which is what CI tests against.
cp .env.example .env
# Set APP_SECRET (openssl rand -base64 48) and POSTGRES_PASSWORD
docker compose up -dOpen http://localhost:3000 and create the first account. The container
healthcheck is at /api/health (not to be confused with /api/health/samples,
which is the device sync endpoint described under
Health data). The first registered account becomes the administrator, and the
sign-up page closes itself as soon as that account exists - later accounts are
created by invitation. See Registration policy if you
want different behaviour. Invitations can be delivered through the
configurable SMTP mailer. No demo account is ever created
automatically.
Administration lives at /admin, reachable from Settings → Administration
for accounts with the ADMIN role. It invites users, activates and deactivates
accounts, configures the SMTP mailer, imports the bundled food databases, runs
and exports the nutrition backfill, shows the AI job queue and reports service
reachability.
Invitations work with or without email. Creating one always shows the single-use link once, on that page, for the administrator to pass on themselves; with SMTP configured the same link is also delivered by mail, and administrators can send individual or batch invitations. Every signed-in member — not only an administrator — can invite another user from Settings once SMTP is configured. If a link is lost, "Resend" issues a new one and revokes the old.
When upgrading an installation that already had exactly one account before
roles were introduced, the database migration automatically promotes that
account to ADMIN. Sign out and back in after upgrading, then open Settings →
Administration. Installations with multiple legacy accounts are deliberately
not changed automatically; an operator can promote a specific account with:
docker compose exec -T db sh -c 'psql -U "$POSTGRES_USER" -d "$POSTGRES_DB"' <<'SQL'
UPDATE "User" SET "role" = 'ADMIN' WHERE "email" = 'you@example.com';
SQLReplace you@example.com with the account email. The Administration link is
only rendered for administrators.
docker compose up -d pulls the prebuilt image named by APP_IMAGE
(ghcr.io/macnite/nutricore:latest by default). To build from source instead:
docker compose up -d --buildTwo images are published to the GitHub Container Registry by the
Publish image workflow, from the same
Dockerfile and with the same set of tags: ghcr.io/macnite/nutricore, which
serves traffic and runs the worker, and ghcr.io/macnite/nutricore-migrate,
the one-shot migration runner and the only one carrying the Prisma CLI.
| Tag | Points at |
|---|---|
latest |
the tip of main, and the most recent v*.*.* release. Convenient, not reproducible |
v1.2.3, 1.2, 1 |
a specific release |
main |
the tip of main — development, may be unstable |
sha-abc1234 |
one exact commit |
Pin a version tag in .env for a reproducible deployment:
APP_IMAGE=ghcr.io/macnite/nutricore:v0.1.0
MIGRATE_IMAGE=ghcr.io/macnite/nutricore-migrate:v0.1.0Pin both, always to the same tag: leaving MIGRATE_IMAGE at latest while
APP_IMAGE is pinned runs one release's migrations against another release's
code, which is the mismatch the migration service exists to prevent.
Releases build for linux/amd64 and linux/arm64; pushes to main build
amd64 only. If the package is private, authenticate first with a personal
access token that has read:packages:
echo "$GITHUB_TOKEN" | docker login ghcr.io -u <username> --password-stdin- Create two datasets, for example
/mnt/tank/apps/nutricore/postgresand/mnt/tank/apps/nutricore/backups. - Put their absolute paths in
.envasPOSTGRES_DATA_PATHandBACKUP_PATH. - Deploy
docker-compose.ymlas a custom app.
The app container runs as an unprivileged user (uid 1001) with
no-new-privileges. PostgreSQL is only reachable on the compose network and is
never published to the host. Terminate TLS at your reverse proxy and set
APP_URL to the https:// URL so session cookies are marked Secure.
All variables are documented inline in .env.example.
| Variable | Required | Notes |
|---|---|---|
APP_IMAGE |
no | Prebuilt image to run; ignored when building locally |
APP_PORT |
no | Host port the app is published on; default 3000 |
MIGRATE_IMAGE |
no | The one-shot migration runner, a separate image (ghcr.io/macnite/nutricore-migrate) tagged in lockstep with the app image. Pin it to the same tag as APP_IMAGE |
APP_URL |
yes | Drives the Secure cookie flag and origin checks |
APP_SECRET |
yes | Minimum 32 characters; validated at start-up |
POSTGRES_PASSWORD |
yes | Compose builds DATABASE_URL from it |
POSTGRES_DB / POSTGRES_USER |
no | Default nutricore for both; compose builds DATABASE_URL from them too |
POSTGRES_DATA_PATH / BACKUP_PATH |
no | Host paths bind-mounted into the database container; default ./data/postgres and ./backups |
DATABASE_URL |
outside compose | Standard PostgreSQL URL |
DEFAULT_LOCALE |
no | de (default) or en |
IMAGE_UPLOAD_MAX_MB |
no | Largest meal, recipe or body-scan image, in whole MiB. Default 5, clamped to 15; anything else falls back to 5. Read at build time for the request ceilings — see below |
OPENFOODFACTS_ENABLED |
no | Default true |
OPENFOODFACTS_USER_AGENT |
recommended | App name plus a real contact address, e.g. NutriCore/0.1 (you@example.com). OFF answers 403 to callers it cannot identify; /admin flags a placeholder value |
OPENFOODFACTS_SEARCH_URL |
no | Search-a-licious service; default https://search.openfoodfacts.org |
OPENFOODFACTS_SEARCH_BACKEND |
no | search-a-licious (default) or legacy to pin /cgi/search.pl |
OPENFOODFACTS_BASE_URL / USDA_BASE_URL |
no | The REST endpoints those adapters call. Defaults are the public services; override only to point at a mirror |
AI_ENABLED / AI_BASE_URL / AI_MODEL |
no | Where the model lives and which one to use; defaults to http://ollama:11434 and qwen3.5:4b. The superseded OLLAMA_BASE_URL / OLLAMA_MODEL are still read as a fallback |
AI_PROVIDER |
no | Which adapter serves AI. ollama is the only implementation and the default |
AI_FALLBACK_MODEL / AI_CONFIDENCE_THRESHOLD |
no | Reserved for a low-confidence fallback policy. Both are accepted and validated at start-up, and nothing reads them yet — no job ever calls a fallback model today |
OLLAMA_MAX_OUTPUT_TOKENS |
no | Hard ceiling on generated tokens; default 2048. An answer stopped by it is reported as Answer cut off |
AI_WORKER_POLL_MS |
no | How often the worker polls the queue; default 2000 |
SEARXNG_URL / SEARXNG_TIMEOUT_MS |
no | JSON source discovery used only after local foods miss |
RETAIN_AI_INPUT_DAYS |
no | Days before ingestion text and source URLs are emptied; default 90, 0 disables |
RETAIN_AI_JOB_DAYS / RETAIN_FAILED_AI_JOB_DAYS |
no | Days before finished AI jobs and their attempts are deleted; defaults 30 and 90, 0 disables |
RETAIN_INVITATION_DAYS |
no | Days before accepted, revoked or expired invitations are deleted; default 30, 0 disables |
REGISTRATION_MODE |
no | bootstrap (default), open or disabled. See Registration policy |
ALLOW_INSECURE_APP_URL |
no | Default false. Allows a production APP_URL that is neither HTTPS nor local, for TLS terminated where the application cannot see it |
TRUSTED_PROXY_HOPS |
no | Default 0. How many reverse proxies sit in front of this deployment; X-Forwarded-For is ignored unless this is set |
INVITATION_EXPIRY_HOURS |
no | Single-use invitation lifetime; default 48 hours |
SMTP_ENABLED / SMTP_HOST / SMTP_PORT / SMTP_SECURE |
no | SMTP delivery. Setting SMTP_HOST makes the environment the source of truth and takes precedence over the Administrator Panel, where SMTP_ENABLED then defaults to true. Without SMTP_HOST the panel's stored settings are used, and the password it holds is encrypted with APP_SECRET |
SMTP_USERNAME / SMTP_PASSWORD |
no | Optional SMTP authentication credentials |
SMTP_FROM_EMAIL / SMTP_FROM_NAME |
with environment SMTP | Sender address and display name for invitation email |
OLLAMA_TIMEOUT_SECONDS |
no | Model generation timeout; default 600 seconds |
BLS_ENABLED |
no | Default true. The bundled Bundeslebensmittelschlüssel 4.0; needs no credentials and makes no network request |
USDA_ENABLED |
no | Default true (changed: it defaulted to false while USDA was API-only). Enables the bundled Foundation and SR Legacy releases |
USDA_API_KEY |
no | Optional. Extends USDA search beyond the bundled releases to newer, Survey (FNDDS) and Branded records. Server-side only |
FATSECRET_ENABLED |
no | Default false. Optional external fallback; nothing changes for an installation that leaves it off |
FATSECRET_CLIENT_ID / FATSECRET_CLIENT_SECRET |
with FatSecret | OAuth 2.0 client credentials, exchanged server-side only. The Platform API also requires this deployment's outbound IP address to be registered with your FatSecret account |
FATSECRET_REGION / FATSECRET_LANGUAGE |
no | Premier-plan localisation. Left empty on a basic plan, where the capability is skipped rather than substituted |
RESEARCH_ENABLED |
no | Default false. The deployment-wide switch for reaching the open web on a user's behalf: AI research source URLs, the component resolver's web tier, and the nutrition backfill, which is refused outright without it. Each also needs the per-user "Allow web research" switch |
RESEARCH_PROVIDER / SEARCH_API_* / OLLAMA_WEB_API_KEY |
no | Reserved for a future search provider and unread today. Leave them unset: SEARXNG_URL alone selects SearXNG |
LOG_LEVEL |
no | debug, info (default), warn, error |
Start-up fails fast with a clear message if a required variable is missing or invalid. Secrets are read from the environment only and are redacted from logs.
Ollama is not started by this compose stack — it already runs elsewhere on your network. NutriCore only stores the connection details:
AI_ENABLED=true
AI_BASE_URL=http://ollama:11434
AI_MODEL=qwen3.5:4b
SEARXNG_URL=http://searxng:8080
OLLAMA_TIMEOUT_SECONDS=600Models are pulled on the Ollama host, never by NutriCore:
ollama pull qwen3.5:4bRun the application and worker separately in development:
npm run dev
npm run workerCompose starts both app and worker; the worker is the same image started
with NUTRICORE_PROCESS=worker, and without it queued meals stay queued.
The admin panel's diagnostics reports an old queued job as a worker error. A deployment
that does not use this Compose file must therefore create a second container
from the same image, with the same environment and NUTRICORE_PROCESS=worker.
Quick-meal images are transient queue payloads in PostgreSQL so a separate
worker container can read them. They are removed as soon as structured meal
extraction succeeds, on terminal failure or administrative cancellation/deletion,
and by the worker's TTL cleanup after at most 24 hours. Only normalized
components and non-sensitive input provenance remain in proposals/jobs.
The maximum meal and recipe image size defaults to 5 MiB and can be changed with
IMAGE_UPLOAD_MAX_MB (a whole number from 1 through 15; a larger value is
clamped to 15). Next.js's two request-body ceilings - the Server Action limit
and the separate one that applies because a middleware matches these routes -
are both derived from it, at 2 x IMAGE_UPLOAD_MAX_MB + 1, since a body scan
carrying a front and a side capture is the largest request the application
makes. Application validation still rejects any single file above the configured
limit.
Both ceilings are read at build time, so changing IMAGE_UPLOAD_MAX_MB at
runtime lowers per-file validation but does not widen the request ceiling the
image was built with.
The two containers have separate environments and nothing makes them agree, so
the worker logs the settings it resolved on startup - AI host, model, timeout,
token cap, whether web research and SearXNG are configured. Compare that line
against the app's .env when a job fails for a reason the app would not have.
The worker deliberately needs no APP_SECRET: it signs no sessions, and reading
a single switch never parses the whole configuration.
SearXNG is intentionally not bundled:
point SEARXNG_URL at the operator's existing instance with JSON output enabled.
SEARXNG_URL itself selects SearXNG; do not set RESEARCH_PROVIDER for it.
Every AI feature is asynchronous. Submitting one inserts a record and an
AiJob and returns immediately; nothing waits for Ollama. That covers all four:
a free-text meal (MEAL_INPUT), logging a recipe to the diary (RECIPE_LOG),
AI food research (RESEARCH), and creating a recipe from a link, an image or
free text (RECIPE_IMPORT). Backfilling missing nutrition (FOOD_ENRICHMENT)
is background work and runs behind all of them, and recipe creation runs ahead
of all of them - see the priority note below.
The review pages refresh themselves while the worker is busy, so a queued job
does not look like a broken one.
Backfilling missing nutrition needs the same permission as any other web
research. FOOD_ENRICHMENT reaches the open web exactly as the component
resolver does, so it is refused unless RESEARCH_ENABLED is on, SEARXNG_URL
is configured, and the "Allow web research" switch is on for both the person
who caused the job - the administrator running the catalogue sweep, or the user
whose quick meal queued the follow-up - and, when the food belongs to somebody,
its owner. A shared catalogue food has no owner, so there the deployment switch
and the requester decide. A job that is not permitted fails as
RESEARCH_NOT_PERMITTED and does not retry; the sweep says so before queuing
anything rather than leaving 25 jobs to fail one by one.
Values it writes are marked AI_ENRICHMENT in FoodNutrient.origin, per
nutrient. A later dataset import therefore reclaims only the nutrients the new
release actually publishes and leaves the backfilled ones in the gaps it still
does not fill - a measured number always wins, and enrichment is no longer lost
on every dataset upgrade.
Reviewed values can be contributed back. npm run datasets:export:enrichment (or the button on /admin) writes
datasets/bundled/ai-enrichment.ndjson.gz and a manifest entry in the same
format as the bundled databases, carrying every approved value with the page and
model it came from. Only foods from a bundled database travel: a BLS code or an
FDC id means the same food on every deployment, and a food you created has no
such identity - so nothing private can reach the artifact by construction.
Nothing is published either: the export writes a file, and contributing it is a
pull request somebody opens, because shipping a value to every NutriCore is a
different decision from approving it for one instance.
An artifact that does ship is applied like any other bundled database, last, and
only into gaps: a nutrient your catalogue already states is never touched, and
everything it writes lands as AI_ENRICHMENT with an estimated source, so it
stays badged as read by a model rather than measured.
A run reads the food's own pages before it searches for its name. A reference URL stored on the food - the label a user typed when creating it, or a provider's product page - is about that food by construction, where a search for a generic name can return a different product that happens to share it. It is also the cheaper and more private path: one fetch of an address already on record instead of naming the food to a search engine. The search still runs when those pages cannot be read or carry no nutrition table, so a product page without one does not leave the food permanently unenrichable. The backfill's own past pages are never re-read - that would make a wrong extraction confirm itself.
Each run asks about gaps no earlier run has tried. One page is only ever
asked for twelve nutrient keys, but taking the first twelve every time made
three quarters of the catalogue unreachable: search already refuses a food with
no energy value, so most foods arrive with the macros present and their first
twelve gaps are exactly what no label publishes - trans fats, omega-3, chloride,
molybdenum. The window now moves through the catalogue in sortOrder and starts
round again only once every gap has been put to a source at least once. Every
completed run records the keys it asked for, including - especially - the runs
that found nothing, since those are the ones the rotation exists for.
A run that could not read a single page because of the network records nothing at all. A blocked address, an oversized page or a 404 is a fact about that page and the candidate is simply skipped, but a timeout, a DNS failure, a 429 or a 5xx is a fact about the moment: reporting it as "nothing found for this food" would both withhold the food for the retry window and mark those nutrients as tried. Those failures are raised instead, so the job's own retry budget decides.
And nothing it finds reaches a food unreviewed. A run records what it read as a proposal and waits, which is the "human approves" clause the rest of the app already honours. Who approves follows who owns the food, exactly as reading it does: a food you created is reviewed by you, on its own page; the shared catalogue is reviewed by an administrator, on /admin. An administrator never sees a proposal for a food somebody owns - they cannot open that food anywhere else either. Approving writes the value only into a gap, so it can never overwrite a measured number; refusing one that is already in use takes it back off the food. Turn on "Apply backfilled nutrition without review" in the privacy settings to skip the queue and write straight through, as it worked before.
A quick meal accepts text, an image, a public recipe URL, or any combination. For a URL, the worker opens the exact submitted address directly (SearXNG is never a proxy). It validates DNS and every redirect against private, loopback, link-local and reserved networks, permits only standard web ports, follows at most three redirects, times out after 10 seconds and accepts HTML/plain text only. A page is read up to 512 KB and the rest is abandoned mid-transfer, with one exception: the read continues, within a 4 MB budget, to pick up complete Recipe JSON-LD blocks that sit past the cap, since publishers routinely put them after half a megabyte of markup and inline script. Nothing else from beyond the cap is kept. No cookies, authorization headers, or application credentials are forwarded. Recipe JSON-LD ingredients are preferred; otherwise navigation, scripts, advertisements and boilerplate are stripped from the visible main content. At most 20,000 sanitized characters, explicitly marked as untrusted data, reach the model. The HTML and extracted page text are never stored; only the submitted URL remains as provenance, so there is no page cache or cache TTL to configure.
Text accompanying a URL is authoritative context. Images and the page are
supporting evidence; the extraction prompt requires conflicts to lower
confidence or create a warning rather than silently inventing a quantity. The
result stops at the same structured component schema used by text and images.
Page nutrition totals are intentionally discarded: component resolution and the
existing local/Open Food Facts/consented-web chain supply nutrition, application
code calculates totals, and the existing approval policy controls diary writes.
SearXNG remains available only to the downstream resolver/research features when
both RESEARCH_ENABLED and the user's research consent allow web research; URL
ingestion itself does not use it for discovery or fallback.
The model decomposes the sentence; it is not asked what a food contains. Each
component it names is then resolved by src/server/component-resolver.ts, which
stops at the first step that yields nutrition:
- Your own database, through the same local-first pipeline the food search uses - substrings, brands and aliases, not exact equality.
- Open Food Facts, reached by that same pipeline when nothing local is convincing, and cached locally as a real food with provenance.
- The open web, only with
RESEARCH_ENABLEDand the per-user consent: SearXNG finds a page, the model reads the per-100 g values off it, and a food is created carrying that URL. - The model's own numbers, last and only if it offered any, stored as a clearly badged estimate.
Nothing is chosen silently. Open Food Facts is a database of branded products, so a generic word like "Brot" resolves to one specific supermarket loaf. A component with no nutrition behind it is reported as skipped rather than logged as zero calories.
Gram weights prefer what the chosen food actually knows, in this order:
- A stated weight or volume — "80 g Haferflocken" is 80 g.
- A serving the food names — "2 Scheiben" against a
Scheibeserving of 30 g is 60 g, and picking a bread with a 45 g slice makes it 90 g. - The food's portion weight — Open Food Facts labels its serving after the amount ("30 g"), never "Scheibe", so a portion word it does not name still uses its serving weight. Without this step "2 Scheiben Brot" resolved to no weight at all and could not be logged however well the bread matched.
- The model's estimate — a portion size is an interpretation of the sentence, while a serving weight is a fact about a food, so this comes last.
A result over 5 kg is treated as a misread unit and falls through to the next step rather than logging it.
By default a proposal is applied to the diary as soon as the worker finishes it, because the review screen used to be reachable only through the redirect that followed submitting a meal: navigate away and the proposal was unreachable, so a queued meal quietly became a meal that was never logged. Everything logged is still recorded on the proposal, every value still carries its provenance, and an estimate is still stored as an estimate.
Turn Settings → Log AI meals automatically off to approve each one by hand. Proposals then wait on the dashboard with a one-click Accept, and the review screen is only needed to pick a different food for a component.
Nothing reaches the diary until a human approves it. On approval, only the
components matched to a food already in the database are logged, each freezing
its own nutrition snapshot; a component the matcher could not resolve is
reported as skipped rather than guessed at. A job that fails is retried up to
maxRetries times (2 by default) before it is marked failed, and an
administrator can hand it a fresh budget from /admin. A reason that cannot
change between attempts — a source page over the size limit, a deleted recipe, a
model that is not installed — fails immediately instead of spending the budget
and holding up the queue.
/admin classifies every failure rather than only storing its message, so
"Ollama request failed" is now separated into timed out, unreachable, error
from Ollama and model not installed, each with the underlying cause chain
(TypeError: fetch failed → Error: connect ECONNREFUSED …) under Show
details. Every attempt is kept, so a job that failed three different ways is
distinguishable from one that failed the same way three times — errorMessage
alone only ever held the last of them.
The same panel manages the queue: filter by status, select rows (select all /
select none), then run again, cancel or delete the selection. Four sweeps act on
a whole status — run all failed again, requeue stuck, delete failed, delete
completed. Requeue stuck is the one to reach for after a worker crash: a job
the worker had claimed stays RUNNING for ever, because nothing in the queue
loop reclaims it.
The image runs the app or the worker depending on NUTRICORE_PROCESS, and its
healthcheck (docker/healthcheck.sh) switches with it. The worker serves no
HTTP, so the previous HTTP-only healthcheck could never pass: the container sat
in health: starting and then went unhealthy, which is what kept a TrueNAS
stack in Deploying even while the worker was processing jobs. The worker now
writes a heartbeat on every queue poll and the healthcheck reads it, allowing a
single job the full OLLAMA_TIMEOUT_SECONDS budget plus a margin before calling
a busy worker unhealthy. A deployment that defines its own healthcheck for the
worker service should use ["CMD", "./healthcheck.sh"].
AI_MODEL selects one model, by name, from those already installed there.
There is no list of models in the compose file because NutriCore neither
downloads nor manages them; adding one would only duplicate state that lives on
the Ollama host. The admin panel's diagnostics reports whether the configured model is
actually installed on the instance NutriCore can reach — it reads the same
AI_BASE_URL and AI_MODEL the AI client uses, so a green row always refers to
the instance that actually serves requests.
OLLAMA_BASE_URL and OLLAMA_MODEL are the superseded spelling of the same two
settings. They are still honoured when AI_BASE_URL / AI_MODEL are unset, so
an existing deployment keeps working, but new ones should set only the AI_*
pair. Do not set both to different values.
Any Ollama model works. qwen3.5:4b is only the default; a small instruct model
is usually the right fit, because both workflows need reliable structured JSON
rather than long reasoning.
If the Ollama container lives in a different compose stack, attach its network
to the app service — see the commented networks block in
docker-compose.yml. Do not hardcode IP addresses.
Three things made AI jobs fail or never finish on a CPU-only Ollama host, and they are worth knowing about because the symptoms were indistinguishable:
- A hidden five-minute ceiling. The request did not stream, so Ollama sent no
response headers until generation had finished, and Node's HTTP client aborts a
request whose headers have not arrived within its own 300-second deadline -
whatever
OLLAMA_TIMEOUT_SECONDSsaid. At roughly 12 tokens a second that made every longer answer impossible to deliver. The request now streams, soOLLAMA_TIMEOUT_SECONDSis once again the only limit that applies. - No ceiling on the answer. A JSON schema with an open-ended object or a long
array becomes a grammar under which the model is never obliged to stop.
OLLAMA_MAX_OUTPUT_TOKENS(default 2048) is that ceiling; an answer stopped by it is reported as Answer cut off, not as malformed JSON. - Reasoning eating the whole budget. A hybrid reasoning model emits its chain
of thought before the grammar-constrained JSON, in its own
thinkingfield, and those tokens count against the same ceiling. A six-word quick meal spent 1950 tokens and 161 seconds thinking and never reached the JSON. Requests now sendthink: false, and when a model ignores that the panel says how much of the budget was reasoning rather than answering. - Validation stricter than the code needed. The grammar enforces shape only -
llama.cpp ignores numeric ranges and string lengths - so a model that did not
know a gram weight wrote
0, and the whole meal was rejected over one value. Answers are now repaired before validation (src/server/ai-repair.ts): an unusable value is dropped, never replaced with a guess, and the component is reported as skipped exactly as before.
Two queue properties matter alongside them. AiJob.priority decides what the
worker takes next, in three bands written by jobPriority: recipe creation (20)
first, then everything else a user is waiting for (10), then background
enrichment (0). Enrichment sits at the bottom because a chronological queue put
every quick meal behind an entire backfill sweep; recipe creation sits at the top
because it is the longest run the worker has and the one whose page a user is
watching fill in, so waiting behind a few quick meals reads as a broken import.
Ties within a band are still served oldest first. And the worker reclaims a job
left RUNNING by a worker that died - a claim is conditional on QUEUED, so
otherwise nothing ever picked it up again.
The compose file passes configuration through env_file: .env and sets only
DATABASE_URL and NUTRICORE_PROCESS under environment:. That is deliberate:
environment: overrides env_file:, so a default written into the compose file
would silently win over the value in .env.
Setting AI_ENABLED=false, or turning AI off per user in Settings, means no
request ever leaves the server for AI purposes.
When a search finds nothing, food search offers Start AI research. The model reconstructs the dish as a list of ingredients with quantities and states the nutrition of the finished dish per 100 g. Nutrition is then resolved in this order, and the review screen always says which of the three it used:
- Calculated from database foods, when every ingredient resolved. These are real values from real foods and are preferred whenever they are available.
- Model-estimated, when an ingredient is not in the database. Stored as an estimate with a reduced confidence score.
- Partially calculated, only when the model supplied no nutrition of its own. Per-100 g values then describe the matched part of the dish alone.
A result that yields no nutrition at all cannot be accepted: it would land in the diary as a 0 kcal entry. Nothing is ever stored without confirmation, and an accepted result is always marked as an AI estimate.
AI food search needs AI_ENABLED (default true), a reachable Ollama with the
configured model, and the per-user AI switch in Settings. It does not need
RESEARCH_ENABLED: estimating from the model alone sends nothing to the web.
Runs are rate-limited per user, and they run in the worker: starting one takes
you straight to the result page, which fills itself in when the run finishes.
Disabled by default (RESEARCH_ENABLED=false), and additionally requires the
per-user "Allow web research" switch. It only adds the ability to give a run
source URLs — AI food search works without it. A source that cannot be fetched
is reported on the review screen and the run continues with the remaining
sources. When enabled, retrieved pages are treated as untrusted data: only
HTTP(S) URLs on standard ports, DNS resolved and checked against loopback,
private, link-local and carrier-grade-NAT ranges, time-limited, stripped of
scripts and markup, and delimited so page text is never read as instructions.
Only the first 512 KB of a page is read and the rest is abandoned mid-transfer;
a larger page is used up to that point rather than rejected, since only the
first 20,000 characters of text ever reach a prompt.
NutriCore searches several databases, in an order that depends on the user's language, and stops as soon as it has a good enough answer. Two of them ship with the application and need no network at all.
| Source | Ships with the app | Responsibility |
|---|---|---|
| Your own foods and recipes | — | Everything you created, plus the public foods a previous lookup already stored |
| BLS 4.0 | yes, 7,140 foods | Generic German foods. The German national nutrient database |
| USDA FoodData Central | yes, 8,156 foods | Generic English/US foods. Optionally extended over the FDC API |
| Open Food Facts | no | Branded and packaged products, and every barcode |
| FatSecret | no | Optional verified fallback, off by default |
German text search:
Local / your own foods
|
BLS 4.0 (bundled, no network)
|
Open Food Facts
|
FatSecret (only if enabled)
|
USDA FoodData Central
English text search:
Local / your own foods
|
USDA FoodData Central (bundled, then the API if a key is set)
|
Open Food Facts
|
FatSecret (only if enabled)
Barcode, in every language:
Local cache / your own foods
|
Open Food Facts
|
FatSecret (only if its plan supports barcodes)
A barcode identifies one packaged product, so the generic ingredient databases are never asked for one: BLS and the USDA generic releases hold no barcodes, and querying them would only add latency to a scan.
Two rules keep this predictable:
- Tier order decides which source is asked. It lives in
src/providers/food-sources.ts, as one map per locale, so adding a language is a new entry rather than a language check spread through the code. - Ranking decides how the answers are ordered. It lives in
src/lib/ranking.tsand is a deterministic weighted sum. It is deliberately too small a term to override an identity match, so a better-trusted generic food can never displace the branded product you actually scanned.
Traversal stops when a result both matches exactly — a barcode, a name, a
synonym or an official translation — and carries at least three of the four
primary nutrients (src/server/food-search-policy.ts). A merely similar
result never stops it, which is why a German search for a branded product
still reaches Open Food Facts. Typing never reaches a network provider at all:
only an explicit request for remote results or a complete barcode does.
A source that is unreachable is skipped, with the results from earlier tiers kept and the next tier still consulted. A provider outage degrades the result list; it never fails the search.
Raw imagery has always been transient: meal and scan uploads carry an explicit expiry, are cleared as soon as they have been processed, and are swept every minute. Everything else used to be kept for ever. The worker now applies these windows too, on its slower cadence:
| Record | Default | What happens |
|---|---|---|
AiIngestionInput text and source URL |
90 days | Emptied, not deleted: Recipe.importId points at the row, and that link is the provenance saying a recipe came from an import |
| Completed AI jobs | 30 days | Deleted with their attempts and proposal |
| Failed AI jobs | 90 days | Deleted with their attempts; kept longer because a failure is what somebody eventually asks about |
| Accepted, revoked or expired invitations | 30 days | Deleted. A live invitation is never touched, whatever its age |
Each window is configurable and 0 disables that sweep for a deployment that
wants to keep everything. Nothing here touches diary entries, foods, recipes,
weights or body measurements: those are the user's records, and they go when the
user deletes them or their account.
Persistence is a licensing question before it is a caching question, so it
belongs to the source (PersistencePolicy in src/providers/food.ts):
| Source | Policy | Effect |
|---|---|---|
| BLS, USDA | permanent | Imported into PostgreSQL and kept |
| Open Food Facts | permanent | Unchanged behaviour; ODbL permits it, and an expired answer is still served during an outage |
| FatSecret | cache with TTL | Content expires after 24 hours and is pruned once no diary entry, favourite or recipe references it. An expired answer is not served during an outage |
The upstream downloads in datasets/raw (291 MB of .xlsx and JSON) are a build
input, not a runtime dependency. They are converted once into about 5 MB of
gzipped NDJSON in datasets/bundled, which is what ships:
npm run datasets:convert # regenerate datasets/bundled after a new release
npm run db:import:foods # import into PostgreSQL (idempotent)
npm run db:import:foods -- bls # just one of themThe import is safe to repeat: it compares the artifact's checksum against the last import and stops immediately when nothing has changed, and it finds each food again by its own identifier (a BLS code, an FDC id) and updates it in place — so food ids, diary entries and recipe ingredients all stay valid. A food that a newer release no longer lists is counted and left alone rather than deleted.
You do not normally have to run anything: the worker imports the bundled databases in the background on start-up, and Administrator Panel → Food databases shows what is bundled versus imported, with a button to import or force a re-import.
- BLS marks a nutrient it never determined with the string
-, a trace withTR, and a value below the detection or quantification limit with<LOD/<LOQ. All four stay unknown; none of them ever becomes0. A zero BLS states as a fact (Logische Null— there is no alcohol in oats) is kept as a real zero. - Units are converted explicitly and only between known pairs. BLS states sodium in mg where NutriCore stores g, and copper, manganese and vitamin B6 in µg where NutriCore stores mg. A future release that changes a unit fails the import instead of rescaling every value by a thousand.
- Both importers record the source's own number and unit alongside the converted value, so a conversion stays auditable.
- Nutrients whose mapping is uncertain are not mapped. BLS 4.0 publishes no selenium and no trans fat; FDC publishes no total omega-3, omega-6 or salt; FatSecret's calcium, iron, vitamin A and vitamin C have been published both as masses and as percentages of a daily value, so NutriCore imports none of them. In every case the nutrient stays unknown rather than becoming a guess.
- Names come from the source. BLS supplies an official German and English name for all 7,140 of its foods, so a German user reads German and an English user reads English; slash-separated synonyms ("Speisesalz/Siedesalz/Tafelsalz") become searchable aliases. Nothing is machine-translated, and a branded product is never translated at all.
A separate one-shot migrate service runs prisma migrate deploy and exits;
the app and the worker both wait for it to succeed before starting, so neither
ever runs against a database that is behind the code. It deliberately does
not run prisma db push, which can drop columns to force the live database
to match the schema.
Migrations used to run from the entrypoint of both long-running containers,
which meant they raced each other on every start and the Prisma CLI had to ship
in the image that serves traffic. The migrate service is the only image
carrying that CLI, and it is short-lived.
Migrations also install the nutrient catalogue, which is reference data rather
than demo data: every stored nutrient value has a foreign key onto it, so a
database without it cannot hold a single food. Adding a nutrient means adding it
to src/lib/nutrients.ts and shipping a migration; a test fails if the two
drift apart. The optional development seed is only ever about sample foods,
diary entries and a demo account.
Three things changed that an existing deployment has to know about. The compose
file itself needs no editing - the migrate service is already in it - but two
of these will stop the app from starting if they are ignored.
APP_URLmust be HTTPS for a public hostname. TheSecureflag on the session cookie is derived from it, so a public deployment on a plain-HTTPAPP_URLwas silently issuing session cookies without it. Start-up now refuses that combination rather than continuing quietly. Loopback,.localand private-range addresses still start over HTTP, since that is the self-hosted LAN case the allowance exists for. If TLS is terminated somewhere the application cannot see, setALLOW_INSECURE_APP_URL=true.- Set
TRUSTED_PROXY_HOPSif you run behind a reverse proxy - almost certainly1.X-Forwarded-Foris no longer trusted by default, because a client could otherwise write its own rate-limit key and never meet a limit. Left at0behind a proxy, sign-in throttling still works but every client shares one bucket. Make sure the outermost proxy strips inboundX-Forwarded-For. - Pin
MIGRATE_IMAGEalongsideAPP_IMAGEif you pin versions at all.
Registration also closes once the first account exists; on an instance that
already has accounts, the sign-up page is now closed rather than open to anyone
who knows the URL. REGISTRATION_MODE=open restores the old behaviour if that
is genuinely wanted.
# Upgrade
docker compose pull && docker compose up -d --build
# Backup
docker compose exec -T db pg_dump -U nutricore -Fc nutricore \
> "${BACKUP_PATH:-./backups}/nutricore-$(date +%F).dump"
# Restore into an empty database
docker compose exec -T db pg_restore -U nutricore -d nutricore --clean --if-exists \
< backups/nutricore-YYYY-MM-DD.dumpBack up before every upgrade, test restores periodically, and keep a copy off the server. A bind-mounted backup on the same pool is not a backup.
Prisma records a migration that failed and then refuses to apply any later one,
which shows up as Error: P3009 in the migrate service log, leaving the app
and the worker waiting on a dependency that never completes:
migrate found failed migrations in the target database, new migrations will not
be applied. The <name> migration started at <time> failed
Each migration is applied to PostgreSQL inside a transaction, so a failed one left nothing of itself behind. The migration service therefore marks it rolled back and applies it again, once, which recovers the stack by itself as soon as an image carrying the corrected migration is pulled. Should the second attempt fail too, it stops with the database error that caused it - fix the migration rather than the record of it. The same recovery by hand:
docker compose run --rm --entrypoint sh migrate -c \
'node ./node_modules/prisma/build/index.js migrate resolve --rolled-back <name>'npm install
cp .env.example .env # set APP_SECRET and POSTGRES_PASSWORD
docker compose up -d db
npx prisma generate
npx prisma migrate deploy
npm run db:seed # nutrient catalogue only; safe anywhere
npm run db:import:foods # optional: the bundled BLS and USDA databases
npm run db:seed:demo # demo account and a month of fake data; needs
# SEED_PASSWORD, refuses to run in production
npm run dev # and, in a second terminal, npm run workernpm run worker is the same queue the worker container drains. Without it,
every AI feature accepts work and nothing ever finishes it.
Useful alongside those: npm run db:studio (Prisma Studio),
npm run datasets:convert (regenerate datasets/bundled from
datasets/raw), npm run datasets:export:enrichment (write the approved
backfill artifact) and npm run website (build the public site with its link
check).
npm run check # lint + typecheck + unit/integration tests
npm test # Vitest only
npm run test:e2e # Playwright; starts the production server itselfMore than 80 Vitest files cover the Mifflin-St Jeor equations and safety limits, TDEE, kcal/kJ, g/kg, sodium/salt, per-100 g and serving scaling, recipe totals and yields, unknown-value handling, coverage, rounding, locale formatting, the ranking function, moving averages, MET-based active calories, the body-scan geometry and its capture checks, the body-progress visualisation geometry, the BLS and USDA readers against real fixture records, the OFF, USDA, FatSecret, SearXNG and Ollama adapters, AI answer repair and failure classification, the research schema and state machine, confidence scoring, the image-upload limit, the SSRF guard, credential redaction and CSV escaping.
Integration tests run against a real PostgreSQL database and assert that one
user cannot read or delete another user's foods, diary entries or weight
history, that account deletion removes every personal record, that the durable
rate limiter counts in the database, and that the retention sweeps delete what
they claim to. Set TEST_DATABASE_URL to enable them; they skip cleanly
without it.
One of them replays the whole migration history into a scratch database seeded
with the rows a running installation holds - including rows written by versions
that have since been replaced. Applying migrations to an empty database, which
is all a plain migrate deploy in CI does, cannot catch a data migration that
only fails on real data.
Seven Playwright suites cover registration, onboarding, the transparent target, sign-in failure and sign-out, creating and logging a food, editing a portion, unknown values rendering as a dash, day navigation, the quick-action menu and its meal, recipe, activity and measurement forms, adding, editing and deleting activity entries, switching each body visualisation off on its own, switching language, switching theme, export, publishing and saving a shared recipe, withdrawing a publication, the admin panel and its diagnostics, and the AI runs
- a queued quick meal, a finished recipe import, and a failed run that says why and can be re-run or thrown away from its row.
Two variables exist only for that suite and belong nowhere near production:
RATE_LIMIT_MULTIPLIER, so many accounts can be registered from one address,
and REGISTRATION_MODE=open, because the default bootstrap mode closes
registration after the first account - which is exactly what
src/server/registration.test.ts and tests/registration-bootstrap.test.ts
assert.
CI runs three jobs (.github/workflows/ci.yml): test for the checks above
plus a production build and the Playwright suites, datasets for importing the
committed artifacts into a real database and then re-importing them to prove it
changed nothing, and docker for building both images - which also asserts
that the migration runner carries the Prisma CLI and that the image serving
traffic does not.
Open Food Facts database content is available under the Open Database License (ODbL); the individual contents are under the Database Contents License. Product images carry their own licence terms. Cached OFF records keep their provider id and retrieval timestamp so they remain identifiable as OFF-derived. Redistributing a database derived from OFF may carry share-alike obligations.
USDA FoodData Central data is generally public domain (CC0) and is attributed regardless. The Foundation Foods and SR Legacy releases are bundled with the application.
Bundeslebensmittelschlüssel (BLS) 4.0 is Germany's national nutrient
database, developed and maintained by the
Max Rubner-Institut, the German Federal Research
Institute of Nutrition and Food. Its own documentation lists among the changes
for version 4.0 a provision free of charge and free of licence
("kostenfreie und lizenzfreie Bereitstellung") and states no restriction on
redistribution. Obtained from https://blsdb.de/download; the version bundled
here is BLS 4.0, data release 2025, recorded in
datasets/bundled/manifest.json with a checksum of both the source files and
the converted artifact.
FatSecret is optional and off by default. Its Platform API terms do not permit building a copy of their database, which is why NutriCore caches FatSecret content for 24 hours and prunes it, rather than storing it the way it stores the sources above.
This is operational documentation, not legal advice. The same information is
shown in the app at /about/data-sources. Add your own licence in LICENSE.
An opt-in two-view capture estimates body circumferences from a front and a side photograph. It runs on CPU in about 80 ms per scan, needs no GPU, no model weights and no third-party service, and nothing leaves the server.
It is an estimate, never a measurement, and it is not validated: it reads the outline of a body and reports what that outline implies, with a range. No body-fat, muscle, water or bone value is produced from a photo. Every value is reviewed by hand before it is recorded, and a rejected capture produces no numbers at all.
The images are held only until the worker has read them - at most ten minutes,
swept every minute - and are deleted in the same transaction that stores the
estimates. Because this stack has no object storage they live in Postgres until
then, so a pg_dump taken inside that window can contain one; the same is true
of meal and recipe-import photos. See docs/BODY_SCAN.md for
the capture conditions, the privacy design and what would have to be measured
before any accuracy claim.
Scanning needs a height in the user's profile, which is the only thing that sets the scale. Each of the two views is captured through two buttons - one asks the phone for its camera, the other always opens the device's file picker - so a photo taken earlier can be used without the camera taking over. Live camera capture additionally needs an HTTPS origin; on a plain-HTTP LAN deployment both buttons open the file picker and everything else works the same.
Weight, body fat, height and waist circumference come in from a phone, two ways. Nothing is written back: this instance reads and does not write.
From an export file. Settings → Import from Apple Health or Health
Connect. The file is parsed in your browser, so an Apple export.xml of
several hundred megabytes works, and the heart rate, sleep, workouts and
clinical documents it also holds never reach the server — only the four values
NutriCore stores are sent. A preview says exactly what would change before
anything is written.
Automatically, from the phone itself. Settings → Sync from a phone
automatically issues a device token, and the phone posts new readings to
/api/health/samples on its own. On an iPhone this needs no app at all: a
Shortcuts personal automation can read the Health store and post it daily. On
Android it needs a small native app, because Health Connect has no web API and a
wrapped PWA cannot reach it.
Each phone gets its own token, shown once and stored only as a hash; revoking one leaves the others working. The endpoint runs the same validation, the same rules and the same writer as the file import, so both obey the one rule that matters: a value you typed is never overwritten. Re-sending readings that are already stored is recognised, not duplicated.
docs/HEALTH_SYNC.md documents the endpoint in full, for anyone writing a client.
Muscle mass is deliberately not imported. Both platforms offer lean body mass, which counts bone, organs and body water alongside muscle and reads several kilograms high; writing it into the muscle column would put a number there that no device ever measured.
Off by default in the only sense that matters: nothing is shared until you open one of your recipes and publish it. There is no feed of anything else, no profiles, no comments, and no ranking - just the recipes members of this installation have chosen to publish, newest first, at Foods → Shared recipes.
Publishing takes a snapshot. The title, description, instructions and tags
are yours to edit in the publish form before anything is public, and what is
published is that text, the ingredient names with their amounts, and the
nutrition calculated from them. Your display name is shown as the author. Your
private recipe is not touched, and no food row of yours becomes readable: a
publication deliberately stores no food ids, because Food.ownerId is the
whole boundary between two members and a shared id would give it away.
Saving somebody's shared recipe copies it:
- you get your own recipe, owned by you;
- each ingredient resolves to a food you may already read - the shared provider row it was made with, matched by provider id or barcode;
- an ingredient with no such row (the author's own custom food) becomes a
private food of yours, marked
IMPORTED, carrying the snapshot's values; - the nutrition is recalculated by the same code path a manual edit uses.
Nothing the author does afterwards reaches your copy. They can edit, withdraw or delete the original and your recipe is unaffected - which is the entire reason for copying rather than linking.
One case is refused rather than fudged. A food from a source whose licence allows caching but not storage (FatSecret, see How long each source may be kept) may be re-used while the shared row still exists, but its values are never copied into somebody's permanent private food. When that row has already been pruned, the ingredient is left out of your copy and named on the recipe, rather than quietly turning expiring provider content into a permanent public dataset.
An AI draft cannot be published: confirm it first. Withdrawing takes a publication out of the list and out of reach by its address, but leaves the copies other members saved alone - they are their recipes now, and an author changing their mind is not a reason to strip the credit off them.
Publishing is rate limited per account. Everything here is instance-local: there is no public access, no federation and no discovery beyond the members of this installation.
A bundled database can be wrong, a barcode product can be entered wrong by whoever scanned it first, and a manufacturer can change a recipe without changing the barcode. Until a member can say so, the only thing they can do about a wrong value is stop using the food - and the next member finds it just as wrong.
Every food in the shared catalogue therefore carries a ⚑ Report button next to its source badge. It opens the food's own nutrition table with an empty column beside it: type what a value should be, leave everything you are unsure about empty, and add a note, a serving weight or a source URL if you have one. An empty field means "no opinion" and never a proposed zero. A report can also be words alone - "this is the drained weight" is not a number.
The button is deliberately absent on a food you created. Those are yours to fix, and an administrator - the only reviewer a report has - cannot read them anywhere in the app. That is the same rule the enrichment review queue is split on.
While a report is undecided, everyone who opens that food is told its nutrition is disputed, without being told who said so. The reporter additionally sees their own report and, afterwards, what became of it.
An administrator decides at Administration → Reported foods. Each report shows, per nutrient, what the food carries, what was reported, and an editable field holding the reported number:
- tick and apply writes the value;
- change the number first applies the administrator's own figure, because "nearly right, but 148 not 152" is a decision the queue has to be able to express;
- leave a row unticked refuses that value; Reject the report refuses all of them;
- the note is what the reporter reads on the food.
Applying is the one path in the application that deliberately overwrites a
value a published source supplied. It is therefore recorded rather than
silent: the nutrient is marked USER_REPORT, and a FoodSource row names the
reviewer, the reported source URL, every key written and the value each one
replaced. Diary entries are untouched - each froze its nutrition when it was
logged - so a correction changes what will be logged from now on and never what
somebody already ate.
A corrected value also outranks the dataset: re-importing BLS or USDA keeps it, and the dataset's own figure for that nutrient is not written. A correction exists because a person decided against the published figure, and an import must not quietly undo that.
Reporting is rate limited per account, one open report per member per food. Corrections are not part of the enrichment export - that artifact carries AI-backfilled values with the page and model behind them, which a human correction has neither of.
Off by default. When switched on in Settings, every meal in the diary shows its
share of the day beside what it holds — 777 / 498 kcal — and a meal over its
share is marked in the warning colour and says so in text, because colour
alone is not information for everybody.
The application does not assert one, and the wording in Settings says so. The DGE states that the available evidence supports no recommendation on how often or how a healthy person should spread intake over the day. The trials on front-loading disagree with each other: the 2022 Aberdeen crossover found no metabolic difference between a large breakfast and a large dinner, only less hunger, while other chrononutrition work reports better outcomes from eating earlier.
So the split is guidance the reader chooses, never a rule. It is a display preference: nothing about the daily target, the macros or the diary changes, and switching it off leaves everything logged exactly as it was.
| Preset | Breakfast | Lunch | Dinner | Snacks |
|---|---|---|---|---|
| Classic (default) | 25 % | 30 % | 25 % | 20 % |
| No snacks | 30 % | 40 % | 30 % | 0 % |
| Front-loaded | 35 % | 35 % | 20 % | 10 % |
| Evening-heavy | 20 % | 30 % | 40 % | 10 % |
The default is the classic distribution taught alongside the DGE's three-to-five-meal advice. It is the default because it maps onto the four meals this diary already has and because it is the one figure a German reader is likely to have met before — not because it is better evidenced than the others. A preset fills the four fields rather than replacing them: any four whole percentages that total 100 can be typed instead, and each shows the kcal it works out to as it is typed.
The shares divide the same daily figure the energy ring shows, so where activity calories count towards the target the meal shares grow with the day's recorded exercise and the four still add up to the number on screen. The rounding remainder is handed out by largest fractional part, so a 1.990 kcal day split 25/30/25/20 reads 498 + 597 + 497 + 398 and not a total one kcal above the day. A meal given a 0 % share never collects a remainder.
A split that does not total 100 % is refused rather than rescaled — a rescaled split is not the one that was typed — and the form shows the running total while it is being edited, so this is visible before saving.
REGISTRATION_MODE decides who may create an account without an invitation.
| Mode | Behaviour |
|---|---|
bootstrap (default) |
The sign-up page works until the first account exists, and that account becomes the administrator. After that, registration is closed and new members are invited |
open |
Anyone who can reach the sign-up page can create an account. Only for a deployment that genuinely wants public sign-up |
disabled |
No self-registration at all, including the first account. For an instance whose administrator is provisioned by other means |
The policy is enforced in the server action, not in the page: posting directly to the registration endpoint on a bootstrapped instance is refused with the same answer whether the instance is closed or already has an account.
There is deliberately no separate "invite-only" mode, because bootstrap
already becomes invitation-only the moment the first account exists, and a mode
that closed registration before any administrator existed would lock the
operator out of their own new instance.
Note that any signed-in member - not only an administrator - can invite another user from Settings when SMTP is configured. If that is not the membership policy you want, an administrator can disable SMTP delivery so invitations are issued only from the Administrator Panel.
- Argon2id password hashing with OWASP-aligned parameters
- Opaque session tokens; only SHA-256 hashes are stored
- Health sync tokens follow the same rule: 256 bits of entropy, only the SHA-256 is stored, shown to the user once, revocable per device, and fixed at issue time to one platform so a token taken off one phone cannot write as the other
- HTTP-only, SameSite=Lax cookies,
SecurewhenAPP_URLis HTTPS, from one shared options helper so no security cookie can drift out of the set - Start-up refuses a production deployment whose
APP_URLis neither HTTPS nor a local address, since that is the configuration that silently dropsSecure - Same-origin validation on state-changing route handlers
- Registration closes after the first account unless
REGISTRATION_MODEsays otherwise; the first-administrator decision is taken under a PostgreSQL advisory lock so two simultaneous registrations cannot both become administrators - Rate limiting on sign-in, registration, invitation redemption, search, export, health sync and research; sign-in is limited per account as well as per address, so a limit does not depend on the proxy configuration being right
- The security-sensitive limits are counted in PostgreSQL, so they survive a restart and are shared across processes rather than resetting with each container; the in-memory limiter remains as the fallback if the database cannot be reached
X-Forwarded-Foris trusted only as far asTRUSTED_PROXY_HOPSsays, counted from the right of the chain, so a client cannot choose its own rate-limit bucket- Zod validation on every input, provider response and AI output
- Ownership checks on every user-owned entity
- SSRF protection with DNS resolution and private-range blocking, and the connection is pinned to the address that passed the check so a name cannot resolve publicly for the check and privately for the fetch
- Secrets only from the environment, never logged, never shown in the UI
- Content-Security-Policy with a per-request nonce, plus
nosniff,Referrer-Policy: same-origin,Permissions-Policyandframe-ancestors 'none'; HSTS is added only whenAPP_URLis HTTPS. The policy allows the camera, because barcode scanning and body-scan capture need it, and denies the rest connect-src 'self': every external provider is reached from the server, never from the browser- No advertising SDKs, no third-party analytics, no telemetry
- Uploaded photos are validated by their bytes, not their filename or declared type, held for minutes at most and swept by the worker
- A published recipe carries no food ids, so sharing cannot expose the author's private food rows; a saved copy only ever references foods the recipient may read
Report the usual caveats: run behind a reverse proxy with TLS, keep the host
patched, restrict access to a trusted network, and rotate APP_SECRET if it is
ever exposed (this invalidates existing sessions).
These have interfaces, schemas and unit tests, but no user-facing flow yet: adaptive TDEE, voice input, meal planning, household sharing and offline sync.
Source discovery in AI food search. A research run is still given its
source URLs by hand. SearXNG discovery itself is implemented and in use — the
component resolver and the nutrition backfill both reach it — but the research
screen does not call it, and RESEARCH_PROVIDER / SEARCH_API_* remain
unread: SEARXNG_URL alone selects SearXNG.
A fallback model. AI_FALLBACK_MODEL and AI_CONFIDENCE_THRESHOLD are
validated at start-up and nothing reads them; a low-confidence answer is
reported as low-confidence rather than retried against a second model.
Activity entries in the JSON export. Everything else personal is in the envelope; the activity log is not there yet.
The FatSecret adapter is complete and unit tested, but it has not been exercised against the live Platform API — no account was available — so treat its first enablement as a configuration exercise and check Administrator Panel → Diagnostics, which reports the IP-allowlist refusal that a self-hosted deployment is most likely to hit.