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 codebases behind one product: a Flutter app, a NestJS + Prisma API, a Next.js admin console and a Dockerised deployment I moved off AWS onto a self-managed Hetzner box when the managed bill stopped paying for itself.

4
Codebases: app, 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. Messaging surfaces

    WhatsApp and Telegram bot integrations plus an MCP server, so logging is not confined to the app

  7. Infrastructure

    Docker Compose behind NGINX across three subdomains, migrated from AWS EC2 to a self-managed Hetzner box

  8. Observability & revenue

    Sentry error tracking, a structured audit log, PostHog product analytics, RevenueCat entitlements and subscription webhooks

Engineering decisions

The calls worth defending

Moving off AWS onto a single Hetzner box

The workload is one Postgres instance and three Node processes. Managed services were charging a platform premium for an architecture that did not need a platform. Consolidating onto a self-managed host behind NGINX cut the monthly cost hard and made the whole system something one person can reason about, at the price of owning the backups, the firewall and the upgrades. That is a trade worth naming rather than glossing over.

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 codebases, 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 box 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.

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 box, on purpose

The original deployment was AWS EC2 with the usual constellation of 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 it is Docker Compose on a self-managed Hetzner host: Postgres, the API, the admin console and the landing site, with NGINX routing three subdomains in front. 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. There are WhatsApp and Telegram bot surfaces, and an MCP server that lets an AI assistant act against the same API. A public scanner endpoint runs the 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 for product analytics with an event taxonomy designed before the events were emitted, Sentry for errors, a structured audit log for the actions that matter legally, and FCM for the notifications that bring people back.