Setup · code · deployment
From an empty machine to a running instance.
NutriCore is one container image, one PostgreSQL database and a
.env file. This page covers what that image contains, what
each variable does, where the food data comes from, how upgrades and
backups work, and which workflow builds what.
Specification
01
Two images, three processes
The build produces a Next.js standalone server: the application
bundle plus its own server.js, without the toolchain
that produced it. The same image runs both the web process and the
AI worker — a single environment variable,
NUTRICORE_PROCESS, decides which.
Migrations run in a third process, from a second image built out of
the same Dockerfile. The migrate service applies
prisma migrate deploy once and exits, and the app and
the worker both wait for it to succeed, so neither ever answers
against a database that is behind the code. It never runs
db push: a schema comparison can drop columns to make a
live database match, which on an upgrade means losing data.
Running migrations from each long-running container's entrypoint, as this used to, meant they raced on every start — and it forced the Prisma CLI into the image that faces the network. The migration image is now the only one carrying that CLI, and CI asserts both halves of that split.
# Two processes from the application image app → node server.js # Next.js standalone worker → node --import tsx src/worker.ts # AI queue, sweeps, dataset import # One process from the migration image, which then exits migrate → prisma migrate deploy # the only image with the CLI # The health check knows which one it is inside app → wget http://127.0.0.1:3000/api/health worker → heartbeat file, written on every queue poll
Request path
Browser ↓ server component Next.js 15 · App Router ↓ server action / route handler src/server/* · business rules ↓ Prisma PostgreSQL 17.6 ↓ only if a tier misses Open Food Facts · FatSecret
Requirements
02
Quick start
Four steps from a clone to an instance holding 15,296 foods. Nothing is downloaded at runtime: the databases are already inside the image.
-
Write a configuration
Copy the example and set the two values that have no safe default: the application secret and the database password.
bashgit clone https://github.com/MacNite/NutriCore.git cd NutriCore cp .env.example .env # APP_SECRET — at least 32 characters openssl rand -base64 48 -
Bring the stack up
Compose starts PostgreSQL, waits for it to report healthy, runs the one-shot
migrateservice, and only then starts the application and the worker.bash# Prebuilt image from the registry docker compose up -d # …or build the image from this checkout docker compose up -d --build docker compose ps curl -s localhost:3000/api/health
-
Import the bundled food databases
Nothing to run: the worker imports them in the background on startup, and it is safe to repeat — the manifest records a checksum per artifact, so a second pass costs one query per dataset and changes nothing. Admin → Food databases shows what is bundled against what is imported, with a button to import or force a re-import. From a checkout, the same work is one npm script.
bash# from a checkout, not from inside the image npm run db:import:foods npm run db:import:foods -- bls # just one of them npm run db:import:foods -- --force # re-import an unchanged dataset
-
Create the first account
Open the instance and register. The first account becomes the administrator; every account after it needs an invitation, so an exposed instance does not quietly collect strangers.
texthttp://localhost:3000 → Register → Onboarding → Diary
03
Configuration
Every setting lives in .env and is documented in
.env.example, which is the authoritative list. These are
the ones worth knowing before the first start.
| Variable | Default | What it decides |
|---|---|---|
APP_SECRET | required | At least 32 characters. Signs sessions. openssl rand -base64 48. |
APP_URL | http://localhost:3000 | Public URL. Cookies are marked Secure when this is https. |
POSTGRES_PASSWORD | required | Compose builds DATABASE_URL from it, so the two cannot drift apart. |
APP_IMAGE | ghcr.io/macnite/nutricore:latest | Pin a version tag for a reproducible deployment. |
MIGRATE_IMAGE | ghcr.io/macnite/nutricore-migrate:latest | The one-shot migration runner. Pin it to the same tag as APP_IMAGE. |
DEFAULT_LOCALE | de | Default for new accounts and signed-out visitors: de or en. |
BLS_ENABLED | true | The bundled German database — 7,140 foods, answered locally. |
USDA_ENABLED | true | The bundled USDA releases — 8,156 foods, answered locally. |
OPENFOODFACTS_ENABLED | true | Branded products over the network. Set a real OPENFOODFACTS_USER_AGENT. |
AI_ENABLED | true | The queue and the review screens. Nothing is called unless AI_BASE_URL answers and the user leaves AI on. |
AI_BASE_URL | — | Your Ollama instance. It is never started by this stack. |
RESEARCH_ENABLED | false | Web research through SearXNG, behind the same review step. |
IMAGE_UPLOAD_MAX_MB | 5 | Whole MiB, 1–15. Enforced before anything is persisted, and read at build time for the request ceiling. |
INVITATION_EXPIRY_HOURS | 48 | How long a single-use invitation link stays valid. |
SMTP_ENABLED | false | Invitations are shown in the interface when mail is off. |
environment: overrides env_file:,
so a default written there would silently beat the value you set in
.env. Only DATABASE_URL and
NUTRICORE_PROCESS are set that way, because they are
derived rather than chosen.
04
Where the food data comes from
Two upstream sources are converted into committed artifacts and imported into PostgreSQL: BLS 4.0 (2025) with 7,140 generic foods across 138 components, and USDA FoodData Central — 363 Foundation Foods plus 7,793 SR Legacy entries.
The conversion is a build step, not a deployment step. It exists because the originals are a 14 MB spreadsheet that inflates to 99 MB of XML and 208 MB of JSON carrying footnotes nothing reads; because an artifact under version control is diffable and identical on every machine; and because the converter can then be held to transcribing the source faithfully while every semantic decision stays in the importer, where it is unit tested against those same files.
# Only needed when refreshing a dataset release npm run datasets:convert # Idempotent — run it on every deployment if you like npm run db:import:foods # datasets/bundled/manifest.json records what was produced { "bls": { "version": "4.0 (2025)", "records": 7140, "artifactSha256": "5a864271f8…" } }
Anything the source did not state stays unstated. A record that
writes -, TR or <LOD is
carried through as written and shown as absent rather than as zero,
because a nutrient nobody measured and a nutrient measured at zero
are not the same claim.
05
Optional AI, on your own hardware
Off by default. Switched on, it points at an Ollama instance you already run — the compose stack never starts one, and models are pulled on the Ollama host. A photo, a sentence or a recipe URL becomes a job; the worker resolves each named component against the food database and files a proposal for review.
AI_ENABLED=true
AI_PROVIDER=ollama
AI_BASE_URL=http://ollama.lan:11434
AI_MODEL=qwen3.5:4b
OLLAMA_TIMEOUT_SECONDS=600
# Web research, behind the same review step
RESEARCH_ENABLED=true
SEARXNG_URL=http://searxng.lan:8080
What the model may do
Name a dish, list its components, state a quantity the source gave, and convert a household measure into grams. Its output is validated against a Zod schema before anything downstream reads it.
What it may not
Supply nutrition values, write a diary entry, or have its unresolved components approved. Page text reaches it as untrusted data, and every fetch is checked against a private-address guard first, so a research job cannot be steered at your LAN.
06
Deployment
Three paths, same image. Pick by how much you want to build yourself.
# Pin both images, always to the same tag
echo 'APP_IMAGE=ghcr.io/macnite/nutricore:v0.1.0' >> .env
echo 'MIGRATE_IMAGE=ghcr.io/macnite/nutricore-migrate:v0.1.0' >> .env
docker compose pull
docker compose up -d
Point the two path variables at datasets rather than at directories inside the app, so an application update never touches the data.
POSTGRES_DATA_PATH=/mnt/tank/apps/nutricore/postgres BACKUP_PATH=/mnt/tank/apps/nutricore/backups APP_PORT=3000 APP_URL=https://nutricore.your.lan
npm ci npx prisma generate npx prisma migrate deploy npm run db:seed # nutrient catalogue npm run db:import:foods # 15,296 foods npm run dev # or: npm run build && npm start npm run worker # second terminal: the queue and the sweeps
07
Upgrade, backup, restore
An upgrade is a pull and a recreate: the migrate
service runs on the way up and the app and the worker wait for it.
If you pin versions, pin APP_IMAGE and
MIGRATE_IMAGE to the same tag — one release's
migrations against another release's code is the mismatch that
service exists to prevent. A backup is a pg_dump into
the mounted backup path, and a restore goes into an empty
database, because a dump restored over live tables leaves rows that
belong to neither.
# Upgrade docker compose pull docker compose up -d # Backup docker compose exec db pg_dump -U nutricore -Fc nutricore \ > backups/nutricore-$(date +%F).dump # Restore into an empty database docker compose stop app worker docker compose exec db dropdb -U nutricore nutricore docker compose exec db createdb -U nutricore nutricore docker compose exec -T db pg_restore -U nutricore -d nutricore < backups/<file>.dump docker compose start app worker
P3009), every later migration is blocked and the app and
the worker wait for ever on a dependency that never completes. The
migration service recognises this, marks the failed migration rolled
back and applies it again — once per start, and loudly. Prisma
runs each migration inside a transaction, so the failed one left
nothing of itself behind.
08
Reading the codebase
The split that matters is between src/lib and
src/server: pure rules that can be tested without a
database, and everything that touches one. Nutrition arithmetic,
unit conversion, ranking, the body-scan geometry and the target
calculation all live on the pure side, which is why
92 test files can cover them without fixtures.
src/ ├── app/ App Router pages, server actions, API routes │ ├── page.tsx Today — the day view │ ├── ai-review/ proposals waiting for a person │ └── api/ health, food search, exports ├── lib/ pure rules — no database, no network │ ├── calories.ts Mifflin–St Jeor, multipliers, guardrails │ ├── nutrients.ts the 47-row catalogue │ ├── body-scan.ts ellipse geometry, no learned weights │ └── url-guard.ts private-address rejection ├── server/ persistence, providers, orchestration │ ├── foods.ts search across tiers │ ├── ai-jobs.ts the queue the worker drains │ └── food-datasets/ BLS and USDA importers ├── providers/ one file per external source └── worker.ts the second process prisma/ 45 models, migrations, seed datasets/bundled/ checksummed artifacts, ~5 MB e2e/ 7 Playwright suites website/ this site — a zero-dependency generator
npm run check # lint + typecheck + unit tests npm test # vitest npm run test:e2e # playwright, against a production build npm run db:studio # prisma studio
09
Continuous integration and releases
Three workflows. ci.yml proves the application works,
publish.yml puts an image in the registry, and
website.yml builds and deploys this site.
| Workflow | Job | What it proves |
|---|---|---|
ci.yml | test | Lint, typecheck, 92 unit and integration test files, a production build, then 7 Playwright suites against a real PostgreSQL 17.6. |
ci.yml | datasets | The committed artifacts still import into a database that has only seen migrations — then the import is repeated to prove it changed nothing. |
ci.yml | docker | Both images build; the migration image's Prisma CLI loads, the image that serves traffic is asserted not to carry it, and @prisma/client still loads there. |
publish.yml | verify → publish | Fast checks first, then the application image and the migration runner, multi-architecture with provenance and an SBOM, tagged by semver on a release. |
website.yml | build → deploy | This site is generated, checked for dead internal links and missing assets, and published to GitHub Pages from main. |
# The website, built the same way CI builds it
node website/build.mjs
python3 -m http.server -d website/dist 4000