Schautrack is a self-hostable, open-source nutrition tracker — log calories, macros, and weight, set goals, and share progress with friends.
Try it: schautrack.com
Tracking nutrition is tedious. But it helps you reach your goals.
Schautrack is built to stay out of your way. Log calories and macros, set goals, and let AI estimate from photos. Simple as that.
- Log calories and macros (protein, carbs, fat, fiber, sugar)
- Saved foods for one-tap quick-add of frequent meals
- Daily goals with color-coded progress tracking
- AI-powered nutrition estimation from food photos (OpenAI, Claude, or Ollama)
- Barcode scanning via OpenFoodFacts
- Weight tracking with unit preference (kg/lbs)
- Optional body-fat percentage per weigh-in — lean/fat mass, category bands, and a Katch-McArdle calorie budget that beats the height/age estimate
- Weight-loss planner: set a goal weight, get a personalized calorie budget (BMR/TDEE), timeline projection, and progress tracking
- Daily notes and recurring todos with streak tracking
- Account linking to share data with friends — granular, opt-in, read-only per category (nutrition, weight, todos, daily notes)
- Two-factor authentication (TOTP) with backup codes
- Brute-force protection with CAPTCHA challenges on login, registration, and verification-email resend
- Invite-only registration mode
- Welcome tour on first login, replayable any time from Settings
- Real-time updates via Server-Sent Events (SSE)
- Public REST API with scoped personal access tokens (docs)
- Docker and Kubernetes ready (~21MB image)
- Android app on Google Play
Source code available at schautrack-android.
The server serves a Digital Asset Links
file at /.well-known/assetlinks.json so that https:// links to your domain
open directly in the app (App Links verification; also used for TWA validation).
The endpoint stays disabled (404) until you provide your app's SHA-256 signing-certificate fingerprint(s), which are specific to your build and must not be copied from elsewhere:
ANDROID_CERT_FINGERPRINTS— comma-separated fingerprint(s) inAA:BB:...:FFform. Find them in the Play Console under Test and release → App integrity → App signing, or from your keystore withkeytool -list -v -keystore <keystore> -alias <alias>.ANDROID_PACKAGE_NAME— defaults toto.schauer.schautrack; override for a fork with a different published package.
mkdir schautrack && cd schautrack
curl -O https://raw.githubusercontent.com/schaurian/schautrack/main/compose.yml
curl -O https://raw.githubusercontent.com/schaurian/schautrack/main/.env.example
mv .env.example .env
sed -i "s/please-change-me/$(openssl rand -hex 32)/" .env
docker compose up -dApp is available at http://localhost:8080.
Upgrading from an earlier
compose.yml? The database volume now mounts at/var/lib/postgresqlinstead of/var/lib/postgresql/data(required for the PostgreSQL 18 image to persist data). If yourdbcontainer is still running with data, back it up before pulling the newcompose.yml:docker compose exec -T db pg_dumpall -U "$POSTGRES_USER" > schautrack-backup.sql # update compose.yml, recreate the stack, then restore: docker compose up -d cat schautrack-backup.sql | docker compose exec -T db psql -U "$POSTGRES_USER"
A Helm chart is available for Kubernetes deployments with bundled PostgreSQL.
helm repo add schautrack https://helm.schautrack.com
helm repo update
helm install schautrack schautrack/schautrack \
--set postgresql.auth.password="$(openssl rand -base64 16)"See Helm Chart Documentation for Ingress, TLS, external databases, AI configuration, and all parameters.
To build from source instead of using pre-built images:
git clone https://github.com/schaurian/schautrack.git
cd schautrack
cp .env.example .env
docker compose -f compose.dev.yml up --buildSettings follow a strict priority hierarchy: environment variables > admin panel (/admin) > user preferences. When a higher-priority source sets a value, lower-priority sources are ignored and their UI controls are disabled.
| Variable | Default | Description |
|---|---|---|
DATABASE_URL |
(empty) | PostgreSQL connection string (e.g. postgresql://user:pass@host:5432/db) |
| Variable | Default | Description |
|---|---|---|
PORT |
3000 |
Port to listen on |
ADMIN_EMAIL |
(empty) | Email that gets access to /admin page |
SUPPORT_EMAIL |
(empty) | Contact email shown on support/error pages |
BASE_URL |
(auto-detect) | Base URL for SEO meta tags (e.g., https://schautrack.com). Auto-detects from request if not set. |
Photo-based nutrition estimation with support for OpenAI, Claude, and Ollama.
Configuration priority: Environment variables > admin panel settings > user settings. When any global AI config is set (provider or key), user personal AI settings are ignored. Users can only bring their own API key when no global config exists.
| Variable | Default | Description |
|---|---|---|
AI_PROVIDER |
(empty) | AI provider to use: openai, claude, or ollama. Required to enable AI features. |
AI_KEY |
(empty) | Global API key (used by all users; overrides personal keys) |
AI_KEY_ENCRYPTION_SECRET |
(empty) | Random 32-byte hex string for encrypting user API keys |
AI_ENDPOINT |
(empty) | Custom endpoint override (e.g., http://your-ollama-host:11434/v1). Leave blank to use provider defaults. |
AI_MODEL |
(empty) | Specify AI model to use (e.g., gpt-4o, claude-sonnet-4-5-20250929, gemma3:12b). Required for OpenAI and Claude. |
AI_DAILY_LIMIT |
(unset → unlimited) | Daily limit for AI requests per user when using global key (0 or unset = unlimited). The app applies no limit unless this is set via env or the admin panel. Note: the Helm chart sets this to 10 by default. |
Note: Ollama models must be downloaded before use. The docker-compose setup automatically pulls the model specified in AI_MODEL. Models specified only in API requests will fail if not pre-downloaded.
Configuring SMTP enables all transactional email flows: password reset, registration email verification, email-change verification, and 2FA reset. Without SMTP configured, none of these flows can deliver their codes.
| Variable | Default | Description |
|---|---|---|
SMTP_HOST |
(empty) | SMTP server hostname |
SMTP_PORT |
587 |
SMTP port |
SMTP_USER |
(empty) | SMTP username |
SMTP_PASS |
(empty) | SMTP password |
SMTP_FROM |
SUPPORT_EMAIL |
From address for emails |
SMTP_SECURE |
false |
Set to true for SSL/TLS |
| Variable | Default | Description |
|---|---|---|
ENABLE_BARCODE |
true |
Enable barcode scanning via OpenFoodFacts. Set false to disable. |
ENABLE_REGISTRATION |
open |
open (anyone can register) or false / invite (requires invite code). Also configurable via /admin. |
UPDATE_CHECK_ENABLED |
true |
Check GitHub (api.github.com) for a newer release so the footer can flag an outdated instance. Set false to opt out of the outbound request — recommended for privacy-sensitive or air-gapped self-hosts. |
UPDATE_PROVIDER |
github |
Which forge hosts the release feed the check reads. github or gitlab. |
UPDATE_REPO |
schaurian/schautrack |
The owner/name the check asks about. Point it at your own fork to track your releases rather than upstream's. |
UPDATE_BASE_URL |
(empty) | API base for a self-hosted forge, e.g. https://gitlab.example.com/api/v4. Empty means the provider's public API. |
Generic OpenID Connect — works with Google, Microsoft, GitHub, GitLab, Apple, Keycloak, Authentik, Authelia, Zitadel, or any OIDC-compliant provider. Users are auto-created on first login and auto-linked by email to existing accounts.
| Variable | Default | Description |
|---|---|---|
OIDC_ISSUER |
(empty) | OIDC issuer URL (e.g., https://accounts.google.com). Enables OIDC when set. |
OIDC_CLIENT_ID |
(empty) | OAuth2 client ID |
OIDC_CLIENT_SECRET |
(empty) | OAuth2 client secret |
OIDC_LABEL |
(derived) | Button label. Defaults to a capitalized brand name inferred from the issuer host. |
OIDC_REQUIRE_INVITE |
false |
Require invite code for OIDC registration (default: OIDC bypasses invite-only) |
OIDC_REDIRECT_URL |
(auto) | Callback URL. Auto-built as <BASE_URL>/auth/oidc/callback when unset. |
A logo is auto-selected from a small bundled set (Google, Microsoft, GitHub, GitLab, Apple, Keycloak, Authentik, Authelia, Zitadel) when the issuer URL contains the brand name. Otherwise the button shows text only.
Example (Google):
OIDC_ISSUER=https://accounts.google.com
OIDC_CLIENT_ID=123456.apps.googleusercontent.com
OIDC_CLIENT_SECRET=GOCSPX-...Add https://<your-domain>/auth/oidc/callback as an authorized redirect URI in your provider's OAuth client.
WebAuthn-based passwordless login with biometric verification. Users can register up to 10 passkeys and use them as their primary login method. Passkeys skip 2FA since they are inherently multi-factor.
| Variable | Default | Description |
|---|---|---|
PASSKEYS_RP_ID |
(empty) | Relying Party ID — your domain (e.g., schautrack.com). Enables passkeys when set. |
PASSKEYS_RP_NAME |
Schautrack |
Display name shown in passkey dialogs |
PASSKEYS_RP_ORIGINS |
https://<RP_ID> |
Allowed origins (comma-separated, for multi-domain setups) |
| Variable | Default | Description |
|---|---|---|
TRUST_PROXY |
true |
Trust X-Forwarded-For / X-Real-Ip headers for rate limiting. Set false for direct-access deployments without a reverse proxy. |
RATE_LIMIT_AUTH |
10 |
Max authentication attempts per 15 minutes per IP |
RATE_LIMIT_STRICT |
5 |
Max requests per 5-minute window per IP on sensitive endpoints (password-reset request/confirm, 2FA reset, email-change request, AI estimate). Also caps POST /api/v1/ai/estimate, there per account — otherwise a token would be a 60x cheaper route to the same paid provider than a browser. |
RATE_LIMIT_API |
120 |
Max requests per minute per IP on the public API (/api/v1). The outer guard: it is the only limit that can throttle an unauthenticated flood. Set well above the auth limiters — a script syncing a day of entries makes dozens of calls in a burst. |
RATE_LIMIT_API_TOKEN |
60 |
Max requests per minute per token on /api/v1. The limit that matters for a legitimate client: per-IP alone means everyone behind one CGNAT shares a bucket. Both limits return 429 with Retry-After. The two endpoints that cost real resources — POST /ai/estimate and GET /barcode/{code} — are additionally capped per account at the app's own rates (RATE_LIMIT_STRICT and RATE_LIMIT_BARCODE); minting more tokens does not raise those. |
RATE_LIMIT_BARCODE |
30 |
Max barcode lookups per minute, applied per IP on the app's own route and per account on GET /api/v1/barcode/{code}. One value for both so the public API cannot become the cheaper path to the same third-party database. Raise it if your users hit 429 while scanning. |
LOGIN_CAPTCHA_GLOBAL_THRESHOLD |
3 |
Failed-login count per account email or client IP (cross-session, 15-minute window) at which login demands a captcha. The per-session threshold stays fixed at 3. Raise only in test harnesses where all clients share one IP (read in internal/handler/login_failures.go, outside the config package). |
STEP_UP_TTL |
30m |
Grace window after fresh primary auth during which sensitive auth-method changes (delete passkey, disable 2FA, change password/email, etc.) are accepted without re-prompting. Any time.ParseDuration value. |
CAPTCHA: A self-generated SVG CAPTCHA guards against brute-force and abuse. It is always required to complete registration and to resend a verification email, and is triggered on login after 3 failed attempts (counted per session, per account, and per client IP). No third-party CAPTCHA service or key is needed — it works out of the box.
CAPTCHA_BYPASS(default: unset) — a test-only escape hatch. When set totrue,VerifyCaptchaaccepts any non-empty answer, disabling CAPTCHA protection entirely. It exists so the end-to-end test suite (compose.test.yml) can drive auth flows headlessly. Never set this in production — doing so removes all brute-force protection from login and registration.
Sessions: there is no session signing or encryption key, and none is needed. Sessions are server-side — the cookie carries only a session ID of 32 bytes from crypto/rand, and all session data lives in the session table. Authenticity is "this row exists", so there is nothing to sign and no key to rotate. (A signed-cookie design needs one because the cookie carries the session data itself; this one does not.) The cookie is HttpOnly, SameSite=Lax, and Secure whenever the request arrives over TLS or with X-Forwarded-Proto: https.
Consequently, restarting the app or changing configuration does not log anyone out. Sessions are invalidated by deleting their rows, which the app does automatically on every credential change — password reset and change, disabling or resetting 2FA, email change, and account deletion. A login additionally issues a fresh session ID and deletes the old row, so a session fixed before login cannot survive it. To end every session for an account out of band, delete its rows directly:
DELETE FROM "session" WHERE (sess::jsonb->>'userId')::int = <user_id>;Earlier releases required a SESSION_SECRET environment variable. It was a leftover from the Node backend, where express-session used it to HMAC-sign the session cookie; the Go rewrite never read it. It has been removed, and setting it now has no effect. The Helm chart still accepts config.sessionSecret so existing values files keep validating, but ignores it.
| Variable | Default | Description |
|---|---|---|
ENABLE_LEGAL |
false |
Set to true to enable /imprint, /privacy, /terms |
IMPRINT_URL |
/imprint |
URL for imprint link |
IMPRINT_ADDRESS |
(empty) | Full name and address (use \n for line breaks) |
IMPRINT_EMAIL |
(empty) | Contact email (rendered as SVG for spam protection) |
| Variable | Default | Description |
|---|---|---|
ROBOTS_INDEX |
false |
Set to true to allow search engine indexing (default: noindex for self-hosters) |
ANDROID_PACKAGE_NAME |
to.schauer.schautrack |
Package name published in /.well-known/assetlinks.json for Android App Links. |
ANDROID_CERT_FINGERPRINTS |
(empty) | Comma-separated SHA-256 signing-cert fingerprint(s) (UPPER:CO:LON form) for App Links. Deployment-specific — see Android App Links. Empty disables /.well-known/assetlinks.json. |
Schautrack has a versioned public API at /api/v1 for scripts and integrations —
logging a meal from a shortcut, pushing readings from a smart scale, pulling your
data into a dashboard.
- Reference:
GET /api/v1/docs— rendered by every instance from its own spec, no token needed. Also generated asdocs/api-v1.md. - Machine-readable:
GET /api/v1/openapi.json(OpenAPI 3.1, no token needed) — point Scalar, Bruno, Insomnia, oropenapi-generatorat it. Also committed atapi/openapi.json.
Create a token under Settings → Account → API tokens. Tokens are scoped, can be given an expiry, and can be revoked at any time. The secret is shown once.
The examples below use schautrack.com; on your own instance use your own host.
A running server puts its URL in the OpenAPI document's servers entry (from
BASE_URL), so tools that read the served spec — Swagger UI's "Try it out"
included — send your token to your instance and nowhere else. The
https://schautrack.com/problems/… URIs in error responses are the exception:
they are stable identifiers, not endpoints, and are the same on every instance
on purpose so clients can branch on them.
curl -H "Authorization: Bearer stk_…" https://schautrack.com/api/v1/me
curl -X POST https://schautrack.com/api/v1/entries \
-H "Authorization: Bearer stk_…" \
-H "Content-Type: application/json" \
-d '{"calories": 450, "name": "Porridge", "protein_g": 12}'Errors are RFC 9457 problem details.
Session cookies are not accepted on /api/v1, which is why it needs no CSRF token.
Retrying a POST is safe if you send an Idempotency-Key — a string you generate
once per logical operation and reuse on retry. The first request executes and its
response is stored; retries replay it instead of logging the meal twice:
curl -X POST https://schautrack.com/api/v1/entries \
-H "Authorization: Bearer stk_…" \
-H "Idempotency-Key: breakfast-2026-08-05" \
-H "Content-Type: application/json" \
-d '{"calories": 450, "name": "Porridge"}'Both artifacts are generated from internal/openapi by go run ./cmd/apidocs, and
go test ./... fails if they drift from the code — including if a route exists that
the spec does not document, or vice versa.
Schautrack runs as a single static Go binary that serves both the JSON API and the compiled React bundle, with PostgreSQL as its only required dependency.
See docs/architecture.md for diagrams of the system context, the request pipeline, how authorization guards compose, how realtime updates fan out across replicas, and the data model.
Contributions are welcome! See CONTRIBUTING.md for guidelines.
Schautrack is free and self-hostable, and it stays that way. If it's useful to you, you can support its development via GitHub Sponsors or PayPal.
This project is licensed under the GNU Affero General Public License v3.0.

