Skip to content
All work
PRODUCTION2026

FitScanned

An AI nutrition tracker, its API, its admin console and the server they all run on.

An AI calorie and nutrition tracker: photograph a meal, get macros. Four apps in one monorepo behind one product: a Flutter app, a NestJS + Prisma API, a Next.js admin console and a landing site, all Dockerised and moved off AWS EC2 onto a self-managed Hetzner server when the managed bill stopped paying for itself, with images left in S3 where they were already cheap.

4
Apps in one monorepo: mobile, API, admin, landing
18
Feature modules in the NestJS API
3
Subdomains served from one orchestrated host
2
Health platforms integrated behind one interface

The problem

Calorie tracking fails for a boring reason: logging a meal takes longer than eating it. Solving that with a photo is easy to demo and hard to ship. A vision model returns prose, not a schema; the photo is a multi-megabyte file taken on a phone in a basement gym with no signal; and the answer has to arrive fast enough that the user doesn't abandon the flow. Underneath that sits a full product: subscriptions, health-platform integrations on two operating systems, an admin console, push notifications, a content pipeline, and a bill that has to stay smaller than the revenue.

The approach

  • Structured output, not prose

    Gemini is called with a JSON schema rather than a prompt-and-parse loop, across two model tiers: a faster, cheaper one for the common case and a stronger one where it earns its latency.

  • An error taxonomy instead of exceptions

    Every failure carries an `errorCode` from a single enum and a typed DTO. A global filter normalises the shape, logs 5xx as errors and 4xx as warnings, and the Flutter client switches on codes rather than on message strings that change with copy edits.

  • An offline queue in front of the network

    The app records the user's intent and replays it when connectivity returns, so a meal logged in a basement gym still lands. Tracking that stops at the door is tracking people abandon.

  • One box, understood end to end

    Docker Compose runs Postgres, the API, the admin console and the landing site behind NGINX across three subdomains. Postgres is deliberately never published to the host, because Docker writes its own firewall rules and a bare port binding would put the database on the open internet.

System architecture

How it is put together

  1. Flutter client

    Feature-first modules, Riverpod + hooks, go_router, Dio, freezed models, dev/prod flavours with separate Firebase projects

  2. Offline & platform services

    Request queue, connectivity watcher, Apple HealthKit and Google Fit behind one interface, FCM push, Shorebird OTA

  3. NestJS API

    18 feature modules over Prisma; typed DTOs, an error-code taxonomy, a global exception filter, throttling and Helmet

  4. AI pipeline

    Sharp image compression, S3 with intelligent tiering and presigned URLs, Gemini structured-schema inference across two model tiers

  5. Admin console

    Next.js App Router dashboard with bearer auth via an Axios interceptor, Recharts analytics, content and user management

  6. Telegram bot

    Webhook-driven bot over the same meal service as the app: photo logging, daily progress, a guided plan flow, and account linking by an expiring one-time code

  7. Infrastructure

    Docker Compose behind NGINX across three subdomains on a self-managed Hetzner server, migrated from AWS EC2; images stay in S3

  8. Repository & release

    One monorepo for app, API, admin, landing and infrastructure; GitHub Actions running Fastlane lanes for signing, builds and store upload; Shorebird for Dart-only patches

  9. Observability & revenue

    Sentry on the API with critical audit events forwarded to it, Crashlytics on the client, PostHog and Firebase Analytics for product data, RevenueCat entitlements and subscription webhooks

Engineering decisions

The calls worth defending

Moving compute off EC2, leaving storage on S3

The workload is one Postgres instance and three Node processes. EC2 and the managed services around it were charging a platform premium for an architecture that did not need a platform, so the API, the database and both web apps moved to a single self-managed Hetzner server behind NGINX. Object storage did not move: S3 with intelligent tiering already prices images by how often they are read, and presigned URLs work the same from any host. The monthly cost fell hard, at the price of owning the backups, the firewall and the upgrades. That is a trade worth naming rather than glossing over.

The bot is a client, not a second backend

The Telegram bot calls the same meal-analysis service the app does, so a photo sent in a chat goes through the same compression, the same structured inference and the same validation, and lands in the same history. Linking an account never asks for a password in a chat window: the app issues a short-lived code and a deep link, and the bot binds the chat to the user who opened it.

Error codes as the client/server contract

Throwing a generic exception and rendering `error.message` is how a UI ends up displaying database text to users. Instead every failure path adds an enum value and a typed DTO, the global filter normalises the response, and the client branches on a stable code. Copy changes stop being breaking changes.

The database is not on the internet

Docker bypasses the host firewall by writing its own iptables rules, so `ports: 5432:5432` publishes Postgres to the world even behind a locked-down UFW. The compose file binds it to the internal network only and carries a comment explaining why, because the next person to want a GUI client will reach for the obvious fix.

Structured model output over prompt parsing

A vision model asked politely for JSON returns JSON most of the time. Asked with a schema, it returns JSON that deserialises into a typed DTO, which means the failure mode is a caught validation error instead of a plausible-looking wrong number on a nutrition label.

Interface

What shipped

  • FitScanned: Point the camera at a plate

    Point the camera at a plate

    Framed capture with on-device guidance before inference runs

  • FitScanned: Macros back in seconds

    Macros back in seconds

    Structured Gemini output → typed DTO → nutrition score and macro split

  • FitScanned: The daily picture

    The daily picture

    Streak, calorie ring, macro targets and hydration in one view

  • FitScanned: Trends that mean something

    Trends that mean something

    Nutrition and health-score trends computed server-side over the history

  • FitScanned: A plan derived from goals

    A plan derived from goals

    Personalised targets with a projected progress curve to a dated goal

  • FitScanned: Health platform data

    Health platform data

    Apple HealthKit and Google Fit behind one platform abstraction

In depth

Four apps, one repository, one product

FitScanned is the project where I own the whole vertical: the Flutter app people install, the NestJS API it talks to, the Next.js console the operators use, and the server all of it runs on. That is unusual enough to be worth explaining, because it changes how the decisions get made.

When the same person owns the client and the server, the contract between them stops being a negotiation and starts being a design choice. The error taxonomy below is the clearest example: it only exists because there was nobody to argue with about whose problem error handling was.

All of it lives in one monorepo: the app, the API, the admin console, the landing site and the infrastructure config. A change to an API contract and the client code that consumes it land in the same commit and go through the same review, so the two cannot drift apart between releases. Mobile releases run on GitHub Actions calling Fastlane, which makes signing, build numbering and the upload to TestFlight and Google Play a lane rather than a checklist, with Shorebird alongside it for Dart-only fixes that should not wait for store review.

The scanning pipeline

A user photographs a meal. What happens next is four steps, and each one had a failure mode worth designing around.

Compression first. A modern phone camera produces a file far larger than the model needs. Sharp resizes and re-encodes server-side before anything else touches it, which cuts both the upload cost and the inference latency.

Object storage, keyed not copied. The image goes to S3 with intelligent tiering. The database stores the key; presigned URLs are generated on demand. Nothing in the API ever streams an image through itself.

Structured inference. Gemini is called with an explicit JSON schema across two model tiers: a fast, cheap default and a stronger model where the extra latency buys accuracy. The response deserialises into a typed DTO or it fails validation. There is no regex, and no "the model usually formats it correctly".

Typed all the way back. The result reaches the client as a DTO with an error code on the failure path, so the app can distinguish "we couldn't identify this" from "you're out of scans today" from "the service is down". Three situations that want three different pieces of UI.

Offline is a product requirement, not a nicety

People log meals in gym basements, on aeroplanes, and in kitchens with bad Wi-Fi. An app that throws away an action because the request failed is an app people stop trusting after the second time.

So the network layer sits behind a queue. The app records what the user meant to do and replays it when connectivity returns, with a connectivity watcher driving the retry. The user sees their meal logged. The sync is the app's problem, not theirs.

Running it on one server, on purpose

The original deployment was an AWS EC2 instance, with S3 for images and the usual managed services around it. The workload is one Postgres database and three Node processes. That is not a platform-scale problem, and it was not a platform-scale bill.

Now the compute is Docker Compose on a self-managed Hetzner server: Postgres, the API, the admin console and the landing site, with NGINX routing three subdomains in front. Images stayed in S3. Intelligent tiering already makes storage cheap, the database holds only object keys, and presigned URLs do not care which host signed them, so moving storage would have been work with nothing to show for it. The cost fell sharply and the whole system became something a single engineer can hold in their head.

The honest counterweight: I now own the backups, the firewall rules, the TLS renewal and the OS upgrades. That is a real cost, paid in attention rather than in dollars. For a product at this stage it is the right side of the trade, and the reasoning is written into the repository so the decision can be revisited rather than inherited.

One piece of that deserves singling out. Postgres is never published to the host. Docker writes its own iptables rules and routes around the host firewall, so the obvious ports: ["5432:5432"] quietly exposes the database to the internet even on a machine with everything else locked down. The compose file binds it to the internal network and carries a comment explaining exactly that, because the next person who wants to connect a GUI client will otherwise "fix" it in about ninety seconds.

Beyond the app

Logging is not confined to the app. A Telegram bot lets people log a meal by sending a photo or a description to a chat, check the day's macros against their targets, list what they have eaten, see a seven-day summary, and walk through a guided flow that builds their nutrition plan one question at a time.

The bot is deliberately thin. Telegram posts each update to a webhook on the API, and the bot hands the photo to the same meal-analysis service the app uses, so compression, structured inference, validation and storage are shared rather than reimplemented. Multi-step flows keep their state on the server, so a half-finished plan survives the user wandering off mid-conversation. Linking a chat to an account works the way a TV signs in to a streaming service: the app issues a short-lived code and a deep link into the bot, and the bot binds the chat to whoever opened it. No password is ever typed into a chat window.

A public scanner endpoint runs the same pipeline without an account, using an optional-identity guard so the same controller serves signed-in and anonymous callers without a second code path.

Around the edges: RevenueCat for entitlements and subscription webhooks; PostHog and Firebase Analytics for product data, with an event taxonomy designed before the events were emitted; Sentry on the API and Crashlytics on the client; a structured audit log for the actions that matter legally, with the critical ones forwarded to Sentry; and FCM for the notifications that bring people back.