NutriCore

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

  • RuntimeNode 22.19.0 on Alpine · PostgreSQL 17.6
  • Imageghcr.io/macnite/nutricore
  • Archlinux/amd64, plus linux/arm64 on release tags
  • LicenceSource-available. No license has been selected yet - see LICENSE.

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.

container
# 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
Why the worker has its own check. The worker serves no HTTP, so an HTTP probe reported it unhealthy for ever — and an orchestrator that waits for every service, TrueNAS among them, then showed the whole stack as deploying while the worker was doing its job.

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

  • HostAnything running Docker; 2 GB RAM is comfortable
  • DatabasePostgreSQL 17.6 (the compose file brings one)
  • DiskRoughly 1 GB once the food databases are imported
  • NetworkNone required after the image is pulled
  • OptionalAn Ollama host for AI; a SearXNG instance for research

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.

  1. Write a configuration

    Copy the example and set the two values that have no safe default: the application secret and the database password.

    bash
    git clone https://github.com/MacNite/NutriCore.git
    cd NutriCore
    cp .env.example .env
    
    # APP_SECRET — at least 32 characters
    openssl rand -base64 48
  2. Bring the stack up

    Compose starts PostgreSQL, waits for it to report healthy, runs the one-shot migrate service, 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
  3. 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
  4. 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.

    text
    http://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.

VariableDefaultWhat it decides
APP_SECRETrequiredAt least 32 characters. Signs sessions. openssl rand -base64 48.
APP_URLhttp://localhost:3000Public URL. Cookies are marked Secure when this is https.
POSTGRES_PASSWORDrequiredCompose builds DATABASE_URL from it, so the two cannot drift apart.
APP_IMAGEghcr.io/macnite/nutricore:latestPin a version tag for a reproducible deployment.
MIGRATE_IMAGEghcr.io/macnite/nutricore-migrate:latestThe one-shot migration runner. Pin it to the same tag as APP_IMAGE.
DEFAULT_LOCALEdeDefault for new accounts and signed-out visitors: de or en.
BLS_ENABLEDtrueThe bundled German database — 7,140 foods, answered locally.
USDA_ENABLEDtrueThe bundled USDA releases — 8,156 foods, answered locally.
OPENFOODFACTS_ENABLEDtrueBranded products over the network. Set a real OPENFOODFACTS_USER_AGENT.
AI_ENABLEDtrueThe queue and the review screens. Nothing is called unless AI_BASE_URL answers and the user leaves AI on.
AI_BASE_URLYour Ollama instance. It is never started by this stack.
RESEARCH_ENABLEDfalseWeb research through SearXNG, behind the same review step.
IMAGE_UPLOAD_MAX_MB5Whole MiB, 1–15. Enforced before anything is persisted, and read at build time for the request ceiling.
INVITATION_EXPIRY_HOURS48How long a single-use invitation link stays valid.
SMTP_ENABLEDfalseInvitations are shown in the interface when mail is off.
The compose file deliberately omits most of them. A key under 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.

bash
# 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.

env
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.

bash
# 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

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.

bash
# 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
If a migration is recorded as failed (Prisma 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

bash
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.

WorkflowJobWhat it proves
ci.ymltestLint, typecheck, 92 unit and integration test files, a production build, then 7 Playwright suites against a real PostgreSQL 17.6.
ci.ymldatasetsThe committed artifacts still import into a database that has only seen migrations — then the import is repeated to prove it changed nothing.
ci.ymldockerBoth 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.ymlverify → publishFast checks first, then the application image and the migration runner, multi-architecture with provenance and an SBOM, tagged by semver on a release.
website.ymlbuild → deployThis site is generated, checked for dead internal links and missing assets, and published to GitHub Pages from main.
bash
# The website, built the same way CI builds it
node website/build.mjs
python3 -m http.server -d website/dist 4000
The dataset job is separate on purpose. Importing 15,296 foods into the test job's database would also put them in front of the end-to-end suite, which searches for foods it creates itself. Isolating it keeps both honest.

Next

Read the source, or look at the screens first.