An open-source, self-hosted food tracker with BYOK (bring-your-own-key) AI plate identification. Snap a photo of your plate and your own AI provider (OpenRouter, Mistral, any OpenAI-compatible endpoint, or Anthropic) estimates the macros. Your key, your provider, your data.
There are no accounts. No sign-up, no login, no password: open the app and start logging. Your diary lives in your browser's own IndexedDB on the device you use, and the app server has no database at all: one stateless container, no secrets, nothing to provision. Optional end-to-end-encrypted sync between devices is a separate service you can ignore forever.
https://openplate.lowcarbcheck.org runs this code. There is nothing to sign up for: open it and start logging, the same as a local install.
It is a demo instance, so treat it as one: no uptime promise, no support, and nothing there is backed up for you. Your diary lives in that browser's storage, and clearing the browser clears it. Self-hosting is the equally supported option, and it is the one below.
curl -O https://raw.githubusercontent.com/LowCarbCheck/openplate/main/docker/compose.yml
docker compose -f compose.yml up -dThat is the whole setup: one container, no database, no .env step, no secret to generate.
The app is reachable at http://localhost:3000.
Upgrading is docker compose -f compose.yml pull && docker compose -f compose.yml up -d. There is no server-side state to
migrate or lose.
The app tells you when there is something to pull. Every six hours the server asks GitHub
whether a newer openplate has been released and shows the answer under Settings > About,
along with the exact version and commit you are running. The request goes from the server,
not from anyone's browser, and carries nothing but your server's IP address. Set
UPDATE_CHECK=off to stop it entirely; the About page then says checks are disabled and no
request leaves the box. Details in
docs/configuration.md.
openplate ships English and German. A visitor who has not chosen yet sees
English; set DEFAULT_UI_LANGUAGE=de to start them in German instead.
DEFAULT_UI_LANGUAGE=deIt is a starting language, not a lock: whatever you set, anyone can switch in Settings and their choice sticks. An unsupported code fails the boot rather than falling back, so a typo is loud instead of serving the wrong language forever.
This is the interface only. It does not translate food names or AI replies.
If you also want end-to-end-encrypted sync across devices,
docker/topologies/compose.sync.yml brings up the app, the
openplate-core service, and the Postgres that
sync (and only sync) needs:
curl -O https://raw.githubusercontent.com/LowCarbCheck/openplate/main/docker/topologies/compose.sync.yml
echo "SERVER_SECRET=$(openssl rand -hex 32)" >> .env
# The URLs a BROWSER will use. Skip these two only for a localhost trial.
echo "PUBLIC_APP_URL=https://openplate.example.com" >> .env
echo "PUBLIC_SYNC_URL=https://sync.example.com" >> .env
docker compose -f compose.sync.yml up -dThere are two larger shapes as well: self-hosted AI, and everything at once.
docker/topologies/README.md is the one-page map of all four,
and compose.full.yml in particular needs four values edited inside the file before it will
work.
Full walkthrough: docs/self-hosting.md and docs/sync.md.
- Open the app and go through the short onboarding: there is nothing to sign up for.
- Go to Settings → AI and connect an AI provider with your own API key. The fastest path is Connect with OpenRouter (one-click OAuth, no key to copy-paste). The key is stored only in that browser and is sent to nobody but the provider you chose.
- Use Scan Plate to upload a photo and get an AI-estimated macro breakdown, or Add to log entries manually.
- Profile → Your data downloads a full JSON backup and imports one. Your diary lives on this device, so that file is how you move it and how you keep it safe. Plate photos stay on the device that took them: they are never exported or synced.
Run any subset. Only the first one is required.
| Component | What it is | Needed? |
|---|---|---|
| openplate (this repo) | The app. Accountless, local-first, stateless, boots with no secrets. | Yes, it is the product. |
| openplate-core | An account service whose first feature is end-to-end-encrypted sync. Stores an email address and ciphertext it holds no key for. It also backs the optional research console at /study (docs/sync.md), which stays dark unless the sync service sets SYNC_RESEARCH=true (off by default). |
No. Everything works without it. |
| openplate-inference | A self-hosted, OpenAI-compatible plate-photo endpoint: open-weight models, your own hardware. | No. BYOK cloud providers work without it. |
| Archived 2026-09-04 (M192), merged into openplate-core: a managed instance's own account now carries the AI allowance, so the separate proxy is gone. | n/a |
| Guide | What it covers |
|---|---|
| Architecture | The four programs, what each one stores, and how they compose |
| Self-hosting | Compose walkthroughs, first run, HTTPS, backups, upgrading |
| Configuration | Every environment variable, the Content-Security-Policy, custom and instance-provided AI endpoints |
| Sync | Enabling sync across devices, the encryption, and the operator's escrowed recovery key |
| Topologies | What to run, from a browser-only install up to a self-hosted household |
| Family setup | Sharing one AI bill across a household, with a spend limit and revocation per person |
| Legal review | Status of the German legal text, machine-translated and awaiting a lawyer |
Repository-level specifications live at the root: .adr/ (architecture decision
records). Start with
ADR-0006 for why this server has no
accounts. Also AGENTS.md: coding guidelines for this repo;
CLAUDE.md imports it. CONTRIBUTING.md: how to send a
pull request. SECURITY.md: reporting a vulnerability.
Requires Node.js ≥ 22 and pnpm, on Linux x64 or arm64. There is no database to install and no service to start alongside it.
The repo pins an exact pnpm in package.json's packageManager field, so run corepack enable first and let it fetch that version: a different global pnpm installs against a
lockfile it does not match. pnpm-workspace.yaml also restricts optional native packages to
Linux, so an install on macOS or Windows silently resolves none of them; build in a container
there.
corepack enable
git clone https://github.com/LowCarbCheck/openplate.git
cd openplate
pnpm install
pnpm dev # http://localhost:3000, nothing to provision firstpnpm typecheck # react-router typegen && tsc
pnpm lint # oxlint --max-warnings 0
pnpm test:unit # node --test against tests/unit/**
pnpm test:integration # node --test against tests/integration/**
pnpm build # react-router build (NODE_ENV=production)
pnpm start # tsx ./server.ts (NODE_ENV=production)A fresh browser holds no diary, so every review of a screen starts on an empty app, and there
is no login on this server to sign in with. pnpm seed:test-account fixes both:
pnpm seed:test-account --diary-only # just the diary file, no network at allThat writes seed-diary.json, a deterministic three-week diary with days over, at and under
the carb ceiling, days with nothing logged, entries in every meal slot and in none, typed
entries beside photographed ones, a weight series and goals. Open /settings/data on a device
and upload it. A fresh browser has to be walked past onboarding first, or /settings/data is
unreachable.
With an openplate-core instance running, the same command also creates an account you can
sign in as, and pushes the diary to it, so a fresh device pulls a populated diary on its first
sign-in:
ADMIN_TOKEN=... SEED_PASSPHRASE=... pnpm seed:test-account --url http://localhost:3000ADMIN_TOKEN mints the invite and SEED_PASSPHRASE becomes the account's password. Neither
has a flag: a credential in argv is a credential in your shell history. The default target is
localhost and any other host needs --allow-remote, because the account this creates is a
real account on whatever instance you point it at, and nothing removes it for you. Delete it
when you are done, with pnpm sync-api accounts delete <id> --yes in openplate-core.
pnpm seed:test-account --help lists the rest: --weeks, --seed, --end-day,
--timezone, --out, --email, --no-push.
Built with React Router v8, Express, and IndexedDB in the browser. Every environment variable
is optional tuning: copy .env.example to .env if you want to change one. The image is
built from Dockerfile.pnpm; its entrypoint just starts the server, with no migration or
provisioning step.
openplate is open source under the MIT License (SPDX: MIT), as is
openplate-core. Run it, read it, change it,
fork it, redistribute it, host it for others, commercially or not, with no restrictions
beyond keeping the copyright and license notice attached to any copy you distribute.