We had a baby. Like every sleep-deprived parent we reached for an app to log the feeds, the diapers and the naps, the stuff you genuinely cannot remember at 4am when the pediatrician asks “and how often is he eating?”. I downloaded a few of the popular ones, started reading their privacy policies, and quietly closed all of them.

Because here is the thing nobody puts on the App Store screenshot: a baby tracker is one of the most intimate datasets a family ever produces. Feeding times, sleep patterns, weight curves, health events, minute by minute, for years. I work next to people who do data-marketing and profiling for a living, and the industry is genuinely, impressively good at it. I did not want my son to have a behavioural profile before he could walk. So I built my own, used it at home, and then made it public to share with colleagues. This is that story, and it is at least as much about how I built it as what I built.

The honest framing: this was a vibe-coding experiment. I wanted to see how far today’s AI coding tools could carry a real, deployed, privacy-serious product, and where they couldn’t, leaving me to put the engineer hat back on.

The thing itself, in one picture

flowchart LR
  subgraph CLIENT["📱 app (Expo / React Native)"]
    UI["log feed · nap · diaper"] --> ENC["encrypt on device<br/>AES-256-GCM"]
    ENC --> DEC["decrypt + compute<br/>stats locally"]
  end
  ENC -->|"ciphertext only"| API["FastAPI"]
  API --> DB[("MongoDB<br/>opaque blobs")]
  API -->|"never sees<br/>plaintext or keys"| API
  CADDY["Caddy · auto-TLS"] --> API
  CADDY --> WEB["static web build"]

It is deliberately small: an Expo (React Native) app that runs on the web and on phones, a single-file FastAPI backend, and MongoDB. The whole product fits in a few files. The one hard constraint: the server only ever stores ciphertext it can’t read. More on that, and its honest limits, below.

BabyCare home screen: greeting, the active-baby switcher, time-since chips, and four big one-tap log buttons

Step 1: a prototype on Emergent

I started on Emergent, an agentic “describe-it-and-it-builds-it” platform, to draft the interface and the core interactions. For a first prototype it shines: you watch the app come alive as you describe it, poke at it, refine. Hard to beat for going from nothing to “oh, that’s the shape of it”.

But the cracks showed fast, all variations of it’s someone else’s environment:

  • it makes a lot of architecture choices for you, not always the ones you’d make;
  • it leans on its own libraries and conventions, so you build on a base you don’t fully control;
  • it’s costly: the welcome credits evaporated after a handful of features;
  • it’s a captive environment: deploying and owning the thing on your own terms isn’t really the point.

Perfect for a prototype. Not where I wanted to live.

Step 2: Claude Code, locally, to take back control

So I pulled the code down and pointed Claude Code at it locally, with one first job: rip out every proprietary, Emergent-specific dependency and make it run on open-source pieces only. It worked like a charm; the app ran fully locally within an afternoon, and I kept iterating from there.

Three things made it fast:

  • Serena to navigate the code. Instead of grepping around and re-reading whole files, the agent used a language-server index to jump straight to symbols and references. Nice on a small codebase; the habit is what scales.
  • Cheaper models for the grunt work. The expensive model plans; a fleet of smaller, cheaper agents do the mechanical edits in parallel. You don’t need a frontier model to rename a prop across ten files.
  • Tests early. A backend test suite well before the app was “done” is what later let me change the entire data model without holding my breath.

The leverage isn’t “AI writes code”. It’s that exploring, refactoring and verifying all got cheap at once, so I made bigger changes than I normally would on a side project.

Step 3: the privacy spine, end-to-end encryption

This is the part I cared about most, and the one you can’t vibe through: you have to understand what you’re doing. The goal: the server stores our baby’s data but cannot read it. Not “we promise not to look”, but “we don’t hold the key” (with one honest caveat I’ll get to).

flowchart TB
  PW["password"] -->|"PBKDF2 600k"| MK["master key<br/>(stays on device)"]
  MK -->|HKDF| AUTH["auth key →<br/>sent to server (bcrypt'd)"]
  MK -->|HKDF| KEK["key-encryption key"]
  DEK["random data key (DEK)"] -->|"AES-256-GCM"| CONTENT["every feed / nap / note"]
  KEK -->|wraps| DEK
  REC["one-time recovery key"] -->|"also wraps"| DEK

It’s a standard zero-knowledge design. Your password derives a master key on the device; from it I split an auth key (the only thing the server sees, stored as a bcrypt hash) and a key-encryption key that never leaves your phone. A random data key encrypts the content and is itself stored wrapped, useless without your password. A one-time recovery key is the escape hatch if you forget it. Email is looked up by a blind index (a keyed hash), so the server finds your account without ever storing your address in clear.

Here is what it looks like as a feed gets logged: readable on the phone, gibberish the instant it leaves, readable again only back on the device.

📱 your phone
🍼 Bottle120 ml
Léo14:32
🔒
⠿⠿⠿
🗄️ our server
··········
stores this · can't read it
logged on the phone

The obvious objection: if the server never knows my password, how do I read my data on a new phone? This is the clever bit, and it’s how password managers like 1Password or Bitwarden sync a vault. Your password derives two keys on the device: an auth key the server only stores a hash of (enough to prove it’s you, useless for decryption), and a key-encryption key that never leaves and unwraps your data key. On a new device you just type your password: it logs you in and, separately, unwraps the key locally. The server holds a hash and a locked blob, and can open neither.

📱 any device
🔑 your passwordonly in your head
🎫 auth key 🔑 KEK · stays
🍼 Bottle120 ml
Léo14:32
🎫
🗄️ our server
auth$2b$ho9…
key🔒 wrapped
a hash and a locked blob,
useless without your password
🔒 your password and the KEK never cross the wire

One honest caveat, because this is a web app. “The server can’t read it” is true at rest: a database dump is just ciphertext, and that is the threat I most wanted to shut down. It is not absolute, though. The key is cached on the device so you aren’t retyping your password every reload, and the app’s code is served by the server on each visit, so in principle a compromised server could ship code that grabs the key. That is why browser encryption is honestly “breach-resistant, honest server”, not “mathematically impossible for anyone but you”. The truly zero-knowledge client is the native app, where the code is signed and shipped through a store and the key lives in the phone’s secure storage. For a family baby log up against a data leak, the web version is already a world apart from the trackers I closed.

The fun knock-on: since the server only sees ciphertext, every statistic (daily averages, day/night sleep, the WHO weight percentiles) is computed on the device after decrypting. The backend is genuinely just an encrypted blob store with auth; a database dump reveals nothing but timestamps.

BabyCare statistics, computed on the device from decrypted data: daily averages, day/night sleep, per-day charts

On top of that, the GDPR basics most apps treat as an afterthought: explicit consent at signup, one-tap data export, and real account deletion that cascades and actually erases everything. Being the data controller of my own family’s data is the whole point.

Step 4: deploy it like I mean it

No clicking around a dashboard. The whole thing ships to a small ARM VM through an Ansible role, the same way I run my other projects:

flowchart LR
  GIT["git push"] --> ANS["ansible role"]
  ANS --> CLONE["clone on server"]
  CLONE --> BUILD["docker build<br/>backend + web"]
  BUILD --> UP["compose up:<br/>mongo · api · static"]
  ANS --> SNIP["drop Caddy site<br/>(auto-TLS)"]
  ANS --> SECRETS["generate JWT + blind-index<br/>keys, once"]

Caddy handles TLS automatically and reverse-proxies the API and the static web build; secrets are generated on first run and never touch git; metrics go to a Prometheus/Grafana stack I already had. A release is a git push and one tagged Ansible run. It’s live at babycare.uningenieur.fr.

Step 5: then just iterate

With the spine in place, the rest was a loop of small improvements, mostly driven by the toughest QA team I have (my wife):

  • “Too many icons.” I looked at what Huckleberry, Baby Daybook and the rest actually do, and cut the home screen to the four things you log at 3am, with the rest behind a “More” sheet, plus a one-tap “repeat last”.
  • “Where do I switch between the twins?” The baby switcher wasn’t discoverable, so it became an obvious pill with a chevron and a proper picker sheet.

Tapping the baby pill opens a sheet to switch baby, with a ‘Manage babies’ link

  • Internationalisation in four languages, a real date picker, hour-scale sleep durations (a full night isn’t “90 min”), growth charts. Each one small, tested, deployed.

None of these are clever. All of them are the difference between an app you tolerate and one a tired parent actually keeps using.

What I actually learned

  • Vibe-coding gets you a long way, but you still need the expertise. The tools were astonishing for scaffolding, refactoring and the mechanical 80%. The decisions that mattered (the crypto design, what the server is forbidden to know, where to draw the line on simplicity) leaned entirely on knowing what I was doing; without that you don’t even know which questions to ask. Paid tiers and bigger models buy more runway, but only so far: past a point, the result is only as good as the engineer steering it.
  • Owning your environment is worth the detour. The hosted prototype was the fastest start and the wrong place to finish. Pulling it local cost an afternoon and bought total control.
  • Privacy is a day-one design choice, not a setting you bolt on. Once the architecture assumes the server can’t read the data, a hundred downstream questions answer themselves.

It does one small thing, help two exhausted parents remember when the baby last ate, without quietly turning our son into a data point. For a side project built in evenings, I’m happy with that.

Stack

Expo / React Native · expo-router · TypeScript · @noble crypto (PBKDF2 · HKDF · AES-256-GCM) · i18n-js · FastAPI · MongoDB · JWT · bcrypt · Docker · Caddy · Ansible · Prometheus / Grafana · built with Claude Code + Serena, prototyped on Emergent.


If you’re building something privacy-first, curious about the vibe-coding workflow, or just want to compare baby-tracker war stories, I’d genuinely enjoy talking it through. Come find me on LinkedIn.