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.