Self-hosted nutrition and body-composition tracking, for you and your AI agent.
NutriPilot tracks food, weight, supplements, water, and caffeine. Log by hand in the web dashboard (search, barcode scan, per-day editing) or let an AI agent do it from natural language through the agent-first API ("I had 200g of chicken for lunch" becomes a structured, dated entry). Smart scales (Withings, Fitbit, Google Fit, Garmin) sync body composition automatically.
I track my own nutrition with it daily. If you're here to read code, start with Architecture and the Security model.
- Food logging: search a local database with fuzzy matching, scan a barcode (camera or manual entry), or log by name. Falls back to OpenFoodFacts and USDA FoodData Central when a barcode isn't known locally. Includes a curated Swiss food database.
- Custom foods with an ownership model: the shared/official catalog is read-only; any user can clone an official food to customize it, or add a brand-new food they own outright. Only the owner can edit or delete their own foods (enforced server-side, not just hidden in the UI).
- Body composition: log weight, body fat %, and muscle mass manually, or connect a smart scale. Sync worker integrations for Withings, Fitbit, Google Fit, and Garmin, plus a generic JSON adapter for anything else.
- Supplements: define supplement profiles with dose and micronutrient content, then log daily intake.
- Water & caffeine: water logged directly; caffeine tracked
automatically from the
caffeine_mgfield on logged foods, no separate entry needed. - Statistics & streaks: 1-365 day rolling stats, adherence, records, and streaks across macros, body comp, and habits.
- Per-user timezone: "today" and daily/weekly boundaries are computed in each user's own configured timezone, not the server's.
- PWA: installable on iOS/Android/desktop.
Analytics: streaks, weight history, calorie trend |
Body composition trends and stats |
Food database with full nutrient breakdown |
graph TD
Browser["Browser"] --> NextJS["Next.js 16 (App Router)"]
NextJS -->|same-origin rewrite: /api/*| API["FastAPI"]
API --> DB[(PostgreSQL 16)]
SyncWorker["Sync worker\n(daily, in-process)"] -->|OAuth-token refresh + fetch| Scales["Withings / Fitbit /\nGoogle Fit / Garmin / generic JSON"]
SyncWorker --> DB
Agent["AI agent"] -->|X-API-Key or JWT, /api/agent/*, /api/foods/*| API
Next.js never calls the backend cross-origin: next.config.ts rewrites
/api/*, /health, /docs, and /openapi.json to the FastAPI service
(INTERNAL_API_URL, defaulting to http://api:8000 inside Compose), so the
browser only ever talks to Next.js. The sync worker runs inside the API
process (app/services/sync_worker.py), refreshing integration OAuth tokens
and pulling the latest body-comp reading on a daily loop started from
app/main.py's lifespan handler.
| Layer | Technology |
|---|---|
| Frontend | Next.js 16 (App Router), TypeScript (strict), Tailwind CSS, shadcn/ui, TanStack Query, Recharts |
| Backend | FastAPI, Python 3.12, SQLAlchemy (async), Alembic |
| Database | PostgreSQL 16 (pg_trgm for fuzzy food search) |
| Auth | JWT (PyJWT) for the dashboard, per-user X-API-Key for the agent (both accepted on agent/food endpoints) |
| External data | OpenFoodFacts + USDA FoodData Central (barcode fallback), Withings / Fitbit / Google Fit / Garmin (body comp sync) |
| Infra | Docker Compose, Caddy (reverse proxy for the production/tunnel profiles) |
- Dual auth on agent-facing routes.
/api/agent/*and/api/foods/*accept either a JWT bearer token (dashboard) or a per-userX-API-Keyheader (agent), via a singleget_current_user_jwt_or_api_keydependency (backend/app/auth/dependencies.py) so behavior can't drift between the two call paths. - Constant-time API key comparison: key lookup narrows to a row by
equality, but the final accept/reject uses
hmac.compare_digestto avoid timing side-channels. - User-scoped queries everywhere, backed by an IDOR test suite
(
backend/tests/test_isolation.py) that asserts one user can never read, modify, or delete another user's foods, logs, integrations, or settings. - Food ownership: the shared catalog (
created_by IS NULL) is read-only for everyone; user-owned foods can only be edited/deleted by their owner. Attempting to edit an official food returns403 FOOD_READ_ONLY; editing someone else's food returns403 NOT_FOOD_OWNER. - Encryption at rest for integration credentials. OAuth tokens and
client secrets stored on an
Integrationrow are encrypted transparently at the SQLAlchemy type layer (app/services/crypto.py, Fernet viaTypeDecorator), so no call site can forget to encrypt.TOKEN_ENCRYPTION_KEYaccepts a comma-separated list of Fernet keys: the first encrypts, all of them can decrypt, enabling zero-downtime key rotation. - SSRF guard (
app/services/url_guard.py) rejects any user/agent-supplied integration URL that resolves to a loopback, RFC1918-private, link-local (including the169.254.169.254cloud metadata address), CGNAT, multicast, reserved, or unspecified address, IPv4 or IPv6, before the sync worker or an integration setup call ever makes the request. - Rate limiting (slowapi) on login/refresh and other sensitive endpoints.
- Security headers on every response (
X-Content-Type-Options,X-Frame-Options,Referrer-Policy,Permissions-Policy, HSTS from the Next.js layer) via middleware in bothapp/main.pyandnext.config.ts. - Production config validation:
app/config.pyrefuses to start withENVIRONMENT=productionifJWT_SECRET,CORS_ORIGINS, orTOKEN_ENCRYPTION_KEYare left at their development defaults./docsand/redocare also disabled in production. - Secrets only via environment variables: nothing sensitive is hardcoded or committed; see Environment variables.
- Docker & Docker Compose
git clone https://github.com/your-org/nutripilot.git
cd nutripilot
cp .env.example .envEdit .env: at minimum set POSTGRES_PASSWORD and JWT_SECRET. Generate a
TOKEN_ENCRYPTION_KEY with:
python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"docker compose up -d --buildThe api container runs alembic upgrade head before starting uvicorn
(see the command: in docker-compose.yml); migrations run once, on
container start, not from the app's lifespan, so multiple workers never race
each other applying the same migration.
| Service | Default URL |
|---|---|
| Dashboard (Next.js) | http://localhost:3099 |
| API (FastAPI) | http://localhost:8099 |
| API docs | http://localhost:8099/docs (dev only; disabled when ENVIRONMENT=production) |
docker compose exec api python -m seedReads SEED_EMAIL / SEED_PASSWORD / SEED_API_KEY from the environment,
falling back to a randomly generated password and API key (printed once, not
stored anywhere) when unset.
For a fuller demo (three months of realistic food/weight/supplement history so every dashboard page and chart has something to show), use:
docker compose exec api python -m seed_demoThis creates a fixed demo@nutripilot.dev account with deterministic fake
data; screenshots in this README should be taken against that account.
To populate the local food database itself (independent of any user), run
docker compose exec api python -m scripts.seed_swiss_foods to import the Swiss food
composition database.
All variables are documented in .env.example.
| Variable | Required | Default | Description |
|---|---|---|---|
POSTGRES_USER |
no | nutripilot |
PostgreSQL user |
POSTGRES_PASSWORD |
yes | none | PostgreSQL password |
POSTGRES_DB |
no | nutripilot |
PostgreSQL database name |
DATABASE_URL |
no | derived from the POSTGRES_* values |
Full async SQLAlchemy DSN, override to point at an external Postgres |
JWT_SECRET |
yes | none | Signs all access/refresh JWTs (min 32 chars) |
TOKEN_ENCRYPTION_KEY |
yes in production | dev-only fixed key | Fernet key(s), comma-separated, encrypting integration credentials at rest |
CORS_ORIGINS |
yes in production | http://localhost:3000 |
Comma-separated list of allowed browser origins |
USDA_API_KEY |
no | none | USDA FoodData Central API key (free tier), used as a barcode fallback |
SEED_EMAIL / SEED_PASSWORD / SEED_API_KEY |
no | random | Used only by backend/seed.py; unset values are generated randomly and printed once |
ENVIRONMENT |
no | dev |
Set to production to enforce the secret checks above and disable /docs//redoc |
NEXT_PUBLIC_API_URL |
no | /api |
Build-time base path baked into the frontend production build |
INTERNAL_API_URL |
no | http://api:8000 |
Where the Next.js rewrite proxies /api/*, /health, /docs, /openapi.json (server-side only, never sent to the browser) |
DOMAIN |
no | localhost |
Hostname Caddy listens on in the production/tunnel Compose profiles |
There is no global agent API key: /api/agent/* and /api/foods/* accept a
per-user API key (issued by seed.py, visible masked in dashboard
settings, and rotatable via POST /api/v1/settings/regenerate-api-key) or a
JWT; see Security model.
cd backend
pip install -e ".[dev]"
ruff check .
pytest --tb=short -q106 tests, run fully offline against an in-memory SQLite database (no Postgres or network access required for most of them). Notable suites:
test_isolation.py: the IDOR suite (cross-user access must always fail)test_food_ownership.py: clone/edit/delete rules for official vs. owned foodstest_encryption.py: integration credentials round-trip through Fernettest_url_guard.py: SSRF guard rejects private/loopback/metadata targetstest_sync_worker.py: integration sync + OAuth token refreshtest_migrations.py(dockerized): spins up a throwawaypostgres:16-alpinecontainer, runs the real Alembic migrations against it, and asserts the resulting schema matches the SQLAlchemy models; automatically skipped if Docker isn't available locally, but runs in CI
CI (.github/workflows/ci.yml) runs ruff check, the full pytest suite, and
a frontend lint + production build on every push/PR.
cd frontend
npm ci
npm run lint
npm run build # production build
npm run dev # dev server on :3000nutripilot/
├── backend/
│ ├── app/
│ │ ├── auth/ # JWT + API-key dependencies
│ │ ├── models/ # SQLAlchemy models
│ │ ├── routers/ # auth, foods, agent/*, dashboard, settings
│ │ ├── schemas/ # Pydantic request/response models
│ │ ├── services/ # crypto, url_guard, sync_worker, food/summary services
│ │ ├── external/ # OpenFoodFacts / USDA clients
│ │ ├── config.py # Settings + production secret validation
│ │ ├── docs.py # agent-facing OpenAPI description
│ │ └── main.py # app wiring, middleware, lifespan
│ ├── alembic/versions/ # migrations (Postgres-only DDL)
│ ├── scripts/
│ │ ├── seed_swiss_foods.py # imports the Swiss food composition DB
│ │ └── populate_serving_sizes.py # backfills serving sizes on Swiss DB foods
│ ├── tests/
│ ├── seed.py # minimal user seed
│ └── seed_demo.py # 3-month demo dataset
└── frontend/
└── src/
├── app/ # Next.js App Router pages
├── components/ # UI, grouped by domain
├── hooks/ # TanStack Query queries + mutations
└── lib/ # api client, types, formatting, validation



