5N3BLOG ← 5N3
Live Pinned

Tracker

A self-hosted life dashboard covering habits, training, nutrition, finance, study and goals in one place, with Garmin sync, OCR scale imports, AI nutrition estimates and a Telegram bot. Built it, shipped it, and use it every day.

StatusLive
StartedMar 2026
Updates0
Stack7 technologies
Technology stack
Next.jsFastAPIPostgreSQLDockerCaddyTesseractTelegram

Overview#

One app to answer one question: is the life going in the direction I pointed it? Habits, workouts, meals and calories, body measurements, finances across currencies, study hours toward certifications, calendar and goals, all in a single dark dashboard and all owned end to end.

It's single-user by design. No accounts system and no multi-tenancy, just a shared password gate with signed, expiring tokens. Built for one person who actually uses it rather than for a hypothetical audience.

Where it came from#

This app exists because of the media server.

That project taught me self-hosting, not as a concept but as a thing I could actually do. Once I could run services on hardware I controlled, the question stopped being can I host something and became what should I host.

The answer was already sitting on my laptop, in a folder of Excel sheets.

I'd been tracking everything in spreadsheets for years. Training. Food. Money across two currencies. Study hours. They worked, technically. But they were miserable to open on a phone, the formulas broke whenever I restructured anything, and nothing talked to anything else. My weight was in one file, my workouts in another, and there was no way to see them on the same chart.

So the first real self-hosted project became: turn the spreadsheets into a proper web app. Same data, same habits, but something I'd actually want to open every day rather than something I opened out of guilt.

That's still the design brief. Every module in here is a spreadsheet tab that grew up.

Architecture#

Five containers behind one reverse proxy:

- Frontend. Next.js 14, Tailwind, recharts. Talks only to the API. - Backend. FastAPI with per-module routers (habits, training, finance, study, meals, analytics, and more), rate-limited login, HMAC-signed session tokens. - Database. PostgreSQL 18, schema managed as plain numbered SQL migrations, 33 of them and counting. - Telegram worker. Long-polling, outbound only. Never opens a port. - Caddy. The only container exposed to the internet. Terminates HTTPS with automatic Let's Encrypt certificates and routes by path, so frontend and API share one origin and CORS never enters the picture.

Note

Postgres, the backend, the frontend and the bot are unreachable from outside the VPS. Only Caddy is published, and the database never touches the internet.

Features#

- Habit checklists with heatmaps, streaks, and links into training and study - Full training module: workouts, plans, week templates, session presets, personal records, body measurements with progress photos - Nutrition and calorie tracking with meal photos and macro targets - Multi-currency finance: accounts, transactions, salary, reconciliation - Study timer with per-certification hour targets - Garmin Connect sync for daily stats - Analytics across every module, finance included, with correlations that only appear once there's enough data to mean anything - A Settings page for API keys and Garmin credentials, encrypted before they reach the database

Habits that tick themselves#

The point of a habit tracker is honesty, and honesty erodes the moment ticking the box is a separate chore from doing the thing.

So some habits no longer have a box. The 18,000-step habit is marked complete by the Garmin sync, from step count, not by me remembering. A nutrition habit completes only when all three main meals are logged and the day is in a calorie deficit and the protein target is met. Those are three conditions I used to check by hand, which in practice meant marking them optimistically.

The rule is deliberate: if the data to decide already exists in the app, the app should decide.

Reading the scale instead of typing it#

Body measurements used to be a form with a dozen fields. Now the smart scale's report image gets dropped onto the page, or sent to the Telegram bot from a phone, and OCR reads the numbers out of it.

Two things made this work on real screenshots rather than ideal ones. The parser tries several Tesseract page-segmentation modes and stops at the first that produces a plausible reading, because a layout that defeats one mode is often trivial for another. And labels are matched by prefix, since OCR truncates them unpredictably.

Nothing is auto-saved. The parsed values appear in a confirmation step first, and the original image is kept alongside the reading. A number you can check against the picture the scale actually showed is auditable. One you can't check is something you have to take on faith.

Estimating nutrition, honestly#

The first attempt used the USDA food database. It was excellent on "chicken breast" and useless on "mas huni" - which is to say, useless on most of what actually gets eaten here. A food composition database only knows ingredients somebody has already tabulated.

A vision model does better, because it knows what's in a composite dish. So the lookup now sends the meal name and the photo to a model and gets macros back.

The trade is real and the app says so rather than hiding it: USDA numbers were laboratory analyses, these are informed guesses, and the same food asked twice can come back slightly different. Every estimated value is stored and labelled as an estimate, nothing is ever auto-saved, and anything corrected by hand is stored as manual and beats the model for that food permanently. A cache means each food is asked once, ever.

That last part is what makes it work over time: the app gets more accurate the more you correct it, and repeat meals cost nothing.

What it taught me#

Strict validation doesn't create bugs, it reveals them, and all at once. Setting extra="forbid" on every response model was a security measure: several update endpoints interpolate field names into SQL as identifiers, which cannot be parameterised, so rejecting unknown keys is what stands between that and injection.

It also turned every pre-existing mismatch between a query and its model into a hard 500. Two shipped. One had Garmin's calorie fields declared on the wrong model entirely. The other was subtler: the queries that fetch a session's exercises select the parent's foreign key so rows can be grouped, and that key reached the model. It only failed once a parent actually had children, so saving a workout preset worked and saving one with an interval in it didn't.

The fix was mechanical. The interesting part is that neither was findable by reading the code, because both depended on the shape of a particular row. So the repo now carries a static check that asserts every router's column list against its response model, with no database and no imports, and the deploy script refuses to ship on a mismatch.

A build that needs the internet is a build that can fail for reasons that aren't yours. next/font/google downloads the font during npm run build. The VPS can't reach Google Fonts, so a font import failed the Docker build and took a whole deployment with it, pointing at a layout file rather than at the network. The font is loaded in the browser now, like the other sites do it.

More write-ups on decisions and mistakes are coming as articles under Software, filed to this project so they appear in the development log below the moment they publish.

Roadmap#

  • More analytics as training for endurance events ramps up
  • Public architecture write-up with diagrams

Roadmap

  • Shipped v1 to productionShipped
  • Garmin Connect syncShipped
  • Demo mode so the app can be shown without exposing real dataShipped
  • Security review covering signed photo URLsShipped
  • upload validation and fail-closed secretsNext
  • Nightly off-site database backups with a restore testShipped
  • Smart-scale reports read by OCR instead of typed inShipped
  • Telegram bot for scale uploads and daily remindersShipped
  • Habits that tick themselves from Garmin steps and the day's nutritionShipped
  • AI nutrition estimates from a meal name and photoShipped
  • with corrections that stickNext
  • Analytics rebuilt as a whole-app reportShipped
  • finance includedNext
  • Settings page with credentials encrypted at restShipped
  • Click an empty slot to add a session from a presetShipped
  • Public architecture write-up with diagramsNext
  • Deeper endurance analyticsNext

Timeline

Tracker learned to read and remind

Smart-scale reports parsed by OCR instead of typed in, a Telegram bot that takes the photo from a phone and reports back what it saved, habits that tick themselves from Garmin steps and the day's nutrition, and AI nutrition estimates from a meal name and photo - with hand corrections that override the model permanently.

Read more

Built and shipped the Tracker

Next.js + FastAPI + Postgres, dockerized, self-hosted. In daily use.

Read more