NutriCore

Self-hosted · no account · no subscription · v0.1.0

Yours to run.
Yours to change.
Yours to keep.

NutriCore is nutrition tracking you install on a machine you own. No account with anyone, nothing to subscribe to, no ads and no telemetry — and in its default configuration, not one request leaves the container. Every source, every target and every food in it is yours to switch off, override or replace.

No account No subscription No telemetry 57 settings in one .env

diary — today
Protein128 / 150 g
Kohlenhydrate171 / 240 g
Fett62 / 80 g

Mittagessen

642 kcal
Linsen, rot, gekocht BLS
180 g · C-0-1-2
208kcal
Olivenöl USDA
12 g · 1 EL
106kcal
Vollkornbrot OFF geschätzt
2 Scheiben · 90 g
328kcal
0 requests that leave your machine in the default configuration
€0 no subscription, no ads, nothing held back behind a plan
57 documented settings — every source and feature can be switched off
15,296 reference foods, searched without a network call

What it actually does

Most trackers are a service you visit. This one is software you run.

Which changes what it can afford to do — and what it has to refuse. Ten decisions that follow from it, each one named next to the file in the source tree that makes it.

01

Your server, your database, your data

NutriCore runs on hardware you own, against a PostgreSQL you own. There is no account with anyone, no telemetry, no analytics, and in the default configuration not one request leaves the container — the food databases it searches are already inside it.

The security work is the ordinary, boring kind: passwords hashed with Argon2id at OWASP parameters, session tokens random 256-bit values of which only a SHA-256 hash is stored, and registration by invitation after the first account. Scan images are discarded once the estimate exists. And the door out is a route, not a support request: the whole diary as JSON, or day and weight histories as CSV, whenever you want them.

  • HashArgon2id · 19 MiB · timeCost 2
  • Sessionshashed at rest — a leaked table hands out nothing
  • Outboundonly the sources you switch on, when you search
Argon2id · 19 MiB · t=2 SHA-256 session tokens Invite-only registration In-process rate limits SSRF guard on every fetch No telemetry
export
# Everything you entered, in formats other tools read.
GET /api/export/json
GET /api/export/diary.csv
GET /api/export/weight.csv
src/lib/auth.ts, src/lib/url-guard.ts, src/app/api/export — the database is yours, and so is the door out of it.
02

Nothing to buy, nobody to sign up with

No subscription, no advertising, no upsell, no feature held back behind a plan. There is no payment path in the codebase at all, because there is nothing to charge for: you install it, and it is yours.

That is not a pricing decision so much as a consequence of the shape. Software that runs on your machine and talks to your database has nobody to bill and nothing to monetise — there is no usage to meter, no profile to sell and no engagement to optimise. The honest caveat: a licence has not been chosen yet, so this is free to run rather than free software.

  • Costa machine you already have, plus its electricity
  • Accountsyours, on your instance, invitation-only
  • LicenceSource-available. No license has been selected yet - see LICENSE.
NutriCore — what it costs
Licence fee0,00 €
Subscription0,00 €
Accounts with anyonenone
Advertisingnone
Features behind a plannone
Telemetry events0
Total0,00 €
There is no billing code in this repository, because there is nothing to bill for. What it does cost is a machine to run it on, and the electricity to keep it there.
03

Configured, not just decorated

57 documented settings in one .env. Every food source can be switched off independently, the AI and the web research are off until you fill in an address, and the interface language, the upload ceiling and the invitation lifetime are all yours to set.

The personalisation goes further than a preferences screen usually does. The calculated energy target can be overridden outright, and any nutrient in the catalogue can carry a goal you set yourself — not just protein, carbohydrate and fat, but fibre, iron, iodine, whatever you are actually watching. Targets are versioned rather than replaced, so changing one keeps the history of what it used to be.

  • File.env.example · every setting documented in place
  • TargetsoverrideKcal plus manualNutrients, keyed by catalogue key
  • Yoursown foods, servings, recipes, activities, aliases
.env
# Every source is independent. Turn one off and
# the search simply stops asking it.
BLS_ENABLED=true
USDA_ENABLED=true
OPENFOODFACTS_ENABLED=false
FATSECRET_ENABLED=false

# Off until configured. Nothing is called before then.
AI_ENABLED=false
RESEARCH_ENABLED=false

DEFAULT_LOCALE=de
IMAGE_UPLOAD_MAX_MB=5
INVITATION_EXPIRY_HOURS=72
57 documented settings in .env.example. Inside the application: an energy override, a personal goal for any nutrient in the catalogue, your own foods, servings, recipes and activities, language per account and a theme with the same three states this page has.
04

The food database ships inside the image

BLS 4.0 — the German national nutrient database, 7,140 generic foods across 138 components — plus USDA Foundation Foods and SR Legacy, 8,156 more. They are converted once into gzipped NDJSON with a checksum per artifact, committed, and imported into PostgreSQL by a command that is safe to run twice.

A search for Haferflocken touches no network at all. That is not a cache warming up; it is the database being present. The 291 MB of upstream downloads the artifacts were generated from stay out of the image entirely.

  • Sourcedatasets/bundled · scripts/convert-food-datasets.mjs
  • Importnpm run db:import:foods — idempotent, proven in CI
  • Sizeabout 5 MB of gzipped NDJSON in the runtime image
datasets/raw BLS_4_0.xlsx · 14 MB FoodData_Central 291 MB · build input datasets/bundled bls-4.0.ndjson.gz usda-*.ndjson.gz manifest.json sha256 per artifact PostgreSQL 15,296 foods idempotent import convert import
Conversion is a build step, not a deployment step: an artifact under version control is diffable, checksummed and identical on every machine. src/server/food-datasets owns every semantic decision; the converter transcribes and nothing else.
05

A search that knows when to stop asking

Sources are consulted in tier order — what you already have, then the bundled reference databases, then the network ones. The traversal stops on the first candidate that is both an identity match and complete enough to log.

Similarity is not enough, and it is the reason the rule is written down in one testable place: Nutella is 0.8 similar to a dozen BLS entries for nut spreads, so a similarity threshold would mean no German search ever reaches a branded product. Tier order decides who is asked; ranking decides how answers are ordered. The two are kept apart on purpose.

  • Rulestrong match ∧ completeness ≥ 0.75
  • Filessrc/server/food-search-policy.ts · src/providers/food-sources.ts
  • Effecthow much network traffic one keystroke may cause
LOCAL own foods, recipes, cache BLS · USDA bundled, no network OPEN_FOOD_FACTS branded, over the network FATSECRET optional, needs a key isSufficientCandidate strongMatch ∧ completeness ≥ 0.75 else: ask the next tier stop no further source is asked
A weak BLS hit still falls through to Open Food Facts — that is the whole point of the completeness half of the rule. Tier order decides who is asked; src/lib/ranking.ts decides how the answers are ordered.
06

47 nutrients, held as a catalogue

Energy, macros, the secondary values a label carries, 14 minerals and 13 vitamins. Every value keeps the unit its source stated, and a food that simply does not state a nutrient shows a dash rather than a zero.

The catalogue is a list in one file, not a column layout. Adding iodine to the application means adding a row and re-running the seed — not a migration, not a schema change, not a backfill. It is also what makes a personal goal for any nutrient possible: the goals are keyed by the same catalogue key.

  • Filesrc/lib/nutrients.ts
  • Categoriesenergy · macro · secondary · mineral · vitamin
  • Missingrendered as absent, never as zero
Energie1.884 kcal
Protein128 g
Ballaststoffe34 g
Eisen14,2 mg
Calcium861 mg
Vitamin B122,9 µg
Selen48 µg
Iod112 µg
src/lib/nutrients.ts — 47 rows across energy, macro, secondary, mineral and vitamin. 13 vitamins, 14 minerals. A new nutrient is a new row; the schema does not move.
07

Targets you can recompute by hand

Mifflin–St Jeor for basal rate, the standard activity multipliers, a conservative goal adjustment. Every intermediate value is kept, so the day's number can be traced back through the multiplier and the adjustment that produced it — or replaced outright with your own.

The guardrails are part of the calculation rather than a warning printed next to it: an automatic adjustment is capped at 500 kcal, an automatic target never falls below 1.200 kcal, and pregnancy, breastfeeding or an age outside the formula's population return medical-guidance-required instead of a target. An override you set yourself is still honoured, so the diary stays usable.

  • Filesrc/lib/calories.ts
  • CapMAX_AUTOMATIC_ADJUSTMENT = 500
  • FloorMIN_AUTOMATIC_TARGET_KCAL = 1200
BMR = 10 w + 6,25 h − 5 a + s

Mifflin–St Jeor · s = +5 male, −161 female

SEDENTARY× 1,2
LIGHT× 1,375
MODERATE× 1,55
ACTIVE× 1,725
VERY_ACTIVE× 1,9
LOSE / GAIN−400 / +300
src/lib/calories.ts — an automatic adjustment never exceeds 500 kcal and an automatic target is never placed below 1.200 kcal. Pregnancy, breastfeeding and minors return medical-guidance-required instead of a number.
08

AI that proposes, and never writes

Off by default. Switched on, it points at an Ollama instance on your own network — so a photo, a sentence or a recipe URL becomes a draft without a word of it reaching anyone else's servers. A background worker runs the job, resolves each named component against the food database, and files a proposal.

A proposal is not an entry. Nutrition is always read from a resolved food, never taken from the model, and a component nothing matched is shown as unresolved rather than quietly estimated. Fetches are checked against a private-address guard before a request is made, and page text reaches the model as untrusted data, never as instructions.

  • Providerssrc/providers/ollama.ts · src/providers/searxng.ts
  • QueueAiJob · AiJobAttempt · AiProposal
  • Defaultoff — nothing is called until you configure it
Input photo · text · URL Worker Ollama on your LAN Resolver names → known foods AiProposal — awaiting review candidates, grams, confidence, warnings Diary on approval only a person is on this arrow
Nothing crosses the last arrow unreviewed, and a component with no resolved food is never written at all. Nutrition always comes from the database, never from the model. src/server/ai-jobs.ts, ai-approval.ts, component-resolver.ts.
09

Body scanning as geometry, not a model

Two silhouettes give a breadth and a depth at the same height. The circumference of that cross-section is the perimeter of an ellipse. There are no learned weights anywhere in it, so there is no training population to fall outside of — and the photographs never leave your instance.

Heights are the same BODY_LANDMARKS the drawn figure is built from, so a scan and the figure it feeds answer to one model of where a waist is. Nothing here has been validated against a tape measure — which is exactly why every result carries an interval and the interface never presents one as a measurement.

  • Filesrc/lib/body-scan.ts · docs/BODY_SCAN.md
  • Puremask in, numbers out — swappable for segmentation later
  • Honestyan interval on every estimate
a FRONT — breadth b SIDE — depth a b CROSS-SECTION at a shared BODY_LANDMARK perimeter ≈ Ramanujan(a, b) ± interval
The heights it measures at are the same BODY_LANDMARKS the drawn figure is built from, so a scan and the figure it feeds cannot drift apart. Images are discarded once a scan finishes; the estimate, and its interval, is what persists.
10

Two images, three processes, one command

A standalone Next.js build, a one-shot migration service the app and the worker both wait for, a health check that knows whether it is inside the web process or the worker, and published multi-architecture images. It runs on a NAS or a spare box; it does not need a cloud.

Continuous integration runs the full production build, the end-to-end suite against a real PostgreSQL, and a complete food-database import — then repeats the import to prove it is idempotent. That last step is the one thing a unit test with a temporary fixture directory cannot show. It also asserts that only the migration image carries the Prisma CLI, and that the image serving traffic still talks to the database without it.

  • Imageghcr.io/macnite/nutricore
  • RuntimeNode 22.19.0 on Alpine · PostgreSQL 17.6
  • Archlinux/amd64 · linux/arm64 on release tags
92unit & integration test files
7Playwright suites
45Prisma models
2architectures on release tags
.github/workflows/ci.yml runs lint, typecheck, unit tests, a production build, the end-to-end suite against a real PostgreSQL 17.6, a full food-database import, and then repeats the import to prove it changed nothing.

Getting it running

A file, two commands, nothing to import.

Compose pulls the published images, applies migrations in a one-shot service the app and the worker wait for, and comes up healthy once PostgreSQL answers. The bundled food databases are imported by the worker in the background, and re-importing them changes nothing.

bash
# 1 - configuration
cp .env.example .env
openssl rand -base64 48   # APP_SECRET

# 2 - bring the stack up
docker compose up -d

# 3 - the worker imports BLS 4.0 and USDA by itself
#     15,296 foods, answered without a network call
curl -s localhost:3000/api/health
{"status":"ok","service":"nutricore","database":"ok","time":"..."}
The first account is the administrator. Registration is invitation-based after that, so an exposed instance does not quietly collect strangers.

Have a look around

The interface, with the data standing still.

Not a picture of the application — the application itself: its own stylesheet, its diary, its food search, its progress chart, with a fixture where the database would be. Nothing is stored and nothing is sent.

Open the demo