openNutrition

openNutrition / Docs

Docs

Everything you need to run openNutrition on your own server. It is a private beta, so some of this needs an invite to the repository first.

On this page

On this page

  1. Getting started
  2. Settings
  3. Your first account
  4. Food databases
  5. Backup and restore
  6. Updating
  7. Linking openGym
  8. AI meal photos
  9. Install on your phone
  10. Integration API
  11. Limitations

Getting started

You need a machine with Docker and Docker Compose, and access to the repository. Compose starts two containers: PostgreSQL and the app.

git clone https://github.com/DuarteSantos8/openNutrition.git && cd openNutrition
cp .env.example .env          # set POSTGRES_PASSWORD, APP_URL and OFF_USER_AGENT (your contact)
docker compose up -d          # PostgreSQL + app on http://localhost:3000
docker compose --profile tools run --rm tools npm run db:seed   # bundled USDA foods + supplement labels

Three settings matter on day one:

  • POSTGRES_PASSWORD: the database password. Compose refuses to start without it.
  • APP_URL: the address people will open. Passkeys are bound to it, so decide before you create accounts.
  • OFF_USER_AGENT: Open Food Facts asks every app to identify itself. Put a real contact address in it.

Put it behind HTTPS

The camera (barcode scanning) and push reminders only work over HTTPS, except on localhost. Put the app behind a TLS reverse proxy, then set APP_URL=https://… and TRUST_PROXY=true.

The image runs as a non-root user, applies database migrations on start and has a health check at /api/v1/health.

Private beta. The repository is not public yet, so git clone needs an invite for now.

Settings

Everything lives in .env. The file .env.example has a comment for every line. These are the ones you will touch.

VariableDefaultWhat it does
DATABASE_URLpglite:./data/pglitepostgres://… in production. Compose sets it for you. pglite: runs Postgres in-process for development and tests.
POSTGRES_PASSWORDrequired with ComposeDatabase password Compose uses.
APP_URLhttp://localhost:3000Public URL. https:// makes cookies Secure. Passkeys are bound to it.
RP_IDhost of APP_URLPasskey domain. Set the shared parent domain on openNutrition and openGym to use the same passkeys on both.
REGISTRATION_OPENtrueNew accounts allowed. The first one always is.
REGISTER_LIMIT_PER_HOUR5Sign-ups per IP address and hour.
TRUST_PROXYfalseBehind a proxy or Cloudflare: read the real client IP for rate limits.
AUTO_MIGRATEfalse, true in the imageApply database migrations on start.
PRIVACY_OPERATOR, PRIVACY_CONTACT, PRIVACY_BACKUP_RETENTIONemptyYour name, contact and backup retention, shown on the app's privacy page.
OFF_ENABLED, OFF_USER_AGENTtrue, placeholderOpen Food Facts lookups. Put a real contact in the user agent.
USDA_ENABLED, USDA_API_KEYfalseLive USDA search. The bulk import needs no key.
DSLD_ENABLEDtrueNIH supplement label search.
AI_PROVIDERnoneollama, openai-compatible or mock. The admin can also set it in the app.
OLLAMA_BASE_URL, OLLAMA_MODELlocalhost:11434, gemma3:4bThe local vision model.
OPENGYM_URLguessedThe openGym server offered when linking. Otherwise gym.<your domain>.
INTEGRATION_ORIGINSorigin of OPENGYM_URLBrowser origins, comma separated, that may read the summary and redeem a pairing code. Never *.
UPLOAD_DIR, MAX_UPLOAD_MB./uploads, 10Where kept meal photos go. By default photos are deleted right after analysis.

Push reminders need HTTPS. Their keys are created on first start and stored in the database, so there is nothing to configure.

Your first account

  1. Open the app and sign up. The first account becomes the admin. The admin moderates contributed products and sets up AI photos.
  2. Add a passkey, so you sign in with your face or fingerprint. A password works as a fallback.
  3. Answer the setup questions. They give you starting targets, and you can run them again any time.
  4. Follow the getting started card: first meal, first weigh-in, passkey, reminders.

If the server is just for you, set REGISTRATION_OPEN=false afterwards and restart.

Before you let other people in, set PRIVACY_OPERATOR and PRIVACY_CONTACT, so the privacy notice says who runs the server.

Food databases

The bundled seed is small on purpose: 363 USDA Foundation foods and 14 supplement labels. For everyday use, import the open datasets. With USDA and the Swiss database in, the catalogue holds roughly 14,800 foods.

USDA and Swiss foods

Download USDA SR Legacy and FNDDS as JSON and the Swiss Food Composition Database workbook into a folder called imports/, then run:

docker compose --profile tools run --rm -v "$PWD/imports:/imports" tools sh -c '
  npm run import:usda -- /imports/FoodData_Central_sr_legacy_food_json_2018-04.json &&
  npm run import:usda -- /imports/surveyDownload.json &&
  npm run import:swiss -- /imports/Schweizer_Nahrwertdatenbank.xlsx &&
  npm run import:swiss-names -- /imports/fr.xlsx /imports/it.xlsx /imports/en.xlsx'

The last line adds French, Italian and English names to the Swiss foods. The Swiss workbook is not bundled. You download it from the source.

Packaged products

Barcodes come from Open Food Facts, live, the first time you scan a product. The product is then stored in your catalogue with its attribution. If you would rather have a country's products in advance, import a filtered dump:

npm run import:off -- ./imports/openfoodfacts-products.jsonl.gz --country=switzerland

Supplement labels

In the app: "Search the NIH label database" and import a label. Or from the command line:

npm run import:dsld -- --query="magnesium glycinate" --limit=25

Refreshing a dataset

Download the new file and run the importer again. Records are updated in place, nothing is duplicated, and your diary history is not touched, because every entry keeps its own snapshot.

SourceUsed forLicence
Open Food Factspackaged foods, barcodesdata ODbL 1.0, images CC BY-SA 3.0
USDA FoodData Centralgeneric foods, detailed nutrientspublic domain, CC0
Swiss Food Composition Database (FSVO)Swiss generic foodsfree use with source acknowledgement
NIH Dietary Supplement Label Databasesupplement labelsUS government work, public domain
Community contributionsmissing productsstored on your server

Backup and restore

Two things hold your data: PostgreSQL, which is everything except photos, and the /data volume, which only matters if people keep their meal photos.

The database

# backup (consistent, while running)
docker compose exec -T db pg_dump -U opennutrition -d opennutrition -Fc > opennutrition-$(date +%F).dump

# restore into an empty database
docker compose stop app
docker compose exec -T db dropdb -U opennutrition opennutrition
docker compose exec -T db createdb -U opennutrition opennutrition
docker compose exec -T db pg_restore -U opennutrition -d opennutrition < opennutrition-2026-10-10.dump
docker compose start app

Photos

docker run --rm -v opennutrition_uploads:/data -v "$PWD":/backup alpine tar czf /backup/uploads-$(date +%F).tgz -C /data .

Automate it with cron or a systemd timer, keep several generations and copy them off the machine. Then test a restore now and then. A backup you never restored is a hope, not a backup.

Every user can also download all their own data as JSON in Settings. That is for moving house, not for restoring a whole server.

Updating

  1. Make a backup. See above.
  2. Get the new code and rebuild: git pull, then docker compose up -d --build.
  3. Migrations run by themselves on start, because the image sets AUTO_MIGRATE=true.

To run migrations by hand instead:

docker compose --profile tools run --rm tools npm run db:migrate

Linking openGym

The two apps stay separate: separate databases, separate deployments, and neither reads the other's database. They talk over HTTP, with a pairing code you type once. There are two directions, and you can use one without the other.

openNutrition reads your training

  1. In openNutrition: Settings, Your data, openGym. "Find" looks for your openGym server at OPENGYM_URL, then at gym.<domain> and opengym.<domain>.
  2. In openGym: Settings, Account, "Pair the mobile app". It shows a code.
  3. Type the code into openNutrition.

Weigh-ins, the goal weight, workouts and the weekly plan come over. Sync runs when you ask, and by itself when Stats loads and the last sync is older than three hours.

  • A weight you entered in openNutrition is never overwritten.
  • While linked, the goal weight follows openGym.
  • openGym is never written to.
  • Disconnecting can remove the imported rows. "Sign out everywhere" in openGym ends the link.

For this direction openNutrition keeps the session token it gets from the pairing code on your openNutrition server. One more reason to run both on machines you trust.

openGym reads your nutrition

  1. In openNutrition: Settings, Your data, Connected apps, Link openGym. It shows a code of 8 characters, valid for 10 minutes, usable once.
  2. In openGym 1.4.5 or later: Settings, Integrations, Sister apps, openNutrition. Enter your openNutrition address and the code.

openGym then reads your daily totals, protein meals and fasting window. Totals only, never a food name. The link shows up under Connected apps, and you can revoke it there at any time.

The openGym web app runs in a browser on its own address, so that address has to be in INTEGRATION_ORIGINS (or be the origin of OPENGYM_URL). The openGym phone apps do not need this.

AI meal photos

Optional, and off by default. Without it the photo screen says it is not set up and offers manual entry. With it, the model only suggests foods and grams. You confirm, and the nutrients come from the food database. Entries from a photo are flagged as estimated.

A local model with Ollama

docker compose --profile ai up -d
docker compose exec ollama ollama pull gemma3:4b

Then in .env:

AI_PROVIDER=ollama
OLLAMA_BASE_URL=http://ollama:11434
OLLAMA_MODEL=gemma3:4b

The admin can also pick the model in the app's settings, from the ones your Ollama server has pulled.

ModelNotes
gemma3:4bThe default and the best fit for a plain CPU: about 1 to 3 minutes per photo on 4 cores.
qwen2.5vl:7bGood food recognition with a GPU (about 6 GB VRAM). Several minutes per photo on a CPU.
gemma3:12bMore accurate, needs a GPU.
llama3.2-vision:11bLarger, slower on a CPU.

Yes, minutes. An analysis runs as a job: you can leave the page, and a push notification tells you when a late result is in.

An API key instead

Set AI_PROVIDER=openai-compatible with OPENAI_COMPAT_BASE_URL, OPENAI_COMPAT_API_KEY and OPENAI_COMPAT_MODEL. It talks to any /chat/completions endpoint that accepts images: vLLM, LM Studio, a llama.cpp server or a hosted API.

Such an endpoint counts as a third party. Every user has to switch on "Send meal photos to the AI provider" in Settings before a photo is sent, and the photo screen names the provider. If your Ollama runs on a machine you do not control, set AI_IS_THIRD_PARTY=true for the same behaviour.

What happens to a photo

The server strips the metadata, resizes the photo to 1280 px and sends that to the model. The answer is checked strictly: if the model adds things nobody asked for, such as calories, the answer is rejected, not repaired. Photos are deleted right after analysis unless the user chooses to keep them for 1, 7 or 30 days.

Install on your phone

openNutrition is a web app that installs to the home screen and then opens like any other app, without the browser bar. Your server needs HTTPS for this.

iPhone

  1. Open your server's address in Safari.
  2. Tap Share, then "Add to Home Screen".

Android

  1. Open your server's address in Chrome.
  2. Open the menu and tap "Install app" or "Add to Home screen".

Offline, the installed app shows the screens it loaded last and queues what you log: food, drinks, supplement intakes and weigh-ins. They are sent when the connection is back.

An iPhone app for your own server is in preparation. It is not in the App Store, and there is no Google Play release.

Integration API

Everything the app does goes through a versioned JSON API under /api/v1. One part of it is made for other apps: a read-only daily summary. It is what openGym reads.

Getting a token

Either create one by hand in Settings, Connected apps, with the summary:read scope. It is shown once, stored as a SHA-256 hash, and you can revoke it. Or let the other app trade a pairing code for one:

POST /api/v1/integration/pair/redeem
Content-Type: application/json

{ "code": "GYM12345", "app": "opengym", "scopes": ["summary:read"] }

A valid code answers 200 with { "token": "on_…", "scopes": ["summary:read"] }. A wrong, used or expired code answers 401. Spaces, dashes and case in the code are ignored. The route is rate limited to 10 tries per 15 minutes per address.

The summary

GET /api/v1/integration/summary?from=2026-10-01&to=2026-10-07
Authorization: Bearer on_…
{
  "schema": "opennutrition.summary.v1",
  "subject": { "id": "uuid" },
  "range": { "from": "2026-10-01", "to": "2026-10-07" },
  "units": { "energy": "kcal", "mass": "g", "weight": "kg" },
  "days": [
    { "date": "2026-10-05", "energy": 2512, "protein": 171, "carbohydrate": 280, "fat": 74,
      "complete": true, "estimatedShare": 0.1, "targets": { "energy": 2600, "protein": 170 } }
  ],
  "bodyWeights": [{ "date": "2026-10-05", "weightKg": 82.1 }]
}
  • Only logged days appear.
  • complete: false means at least one entry had no energy value, so the number is a lower bound.
  • days[].proteinMeals counts the meals that reached the protein threshold. It is left out, never null, when the app cannot know.
  • fasting appears at the top level only while the fasting timer is on.
  • No food names, no photos, no supplement details.

New fields are added to v1. A breaking change would get a v2.

Limitations

Things it does not do, or does not do well yet. Better you read them here than find them at dinner.

  • Offline covers logging, not everything. Creating recipes, products or supplements, editing settings and searching beyond cached results still need a connection.
  • One process. Rate limits and the barcode miss cache live in memory, per process.
  • Search is a heuristic. It prefers plain everyday foods and what you log often, but a rare query can still put an odd match first.
  • Barcode scanning on iPhone depends on the browser. Where the camera is not available, you can type the barcode.
  • Supplement chemistry covers 19 exactly defined compounds. Other forms keep their compound weight until you enter the label's elemental value.
  • IU conversions exist for vitamin D only. Vitamin A and E values in IU are kept but not added to totals.
  • Recipes snapshot their ingredients when saved. Editing a food later does not change a recipe until you save the recipe again.
  • Moderation is simple: approve or reject a contribution. No edit history, no voting.
  • Charts have tooltips and table views, but no zooming and no export.
  • Languages: six. English, German, French, Italian, Spanish and Portuguese.
  • Store apps: none yet. The web app installs on Android and iPhone.
  • The name is also used by an unrelated open dataset project, and still has to be checked before a public release.