Skip to content
My own product4 codebases, 1 person

Anonymous India

A social product where people post anonymously. I own all of it: the Flutter app on both stores, the NestJS API, the public site, the admin console, the database design and the infrastructure it runs on. Nobody reviews my pull requests and nobody else gets paged.

Codebases

4

app, API, site, admin console

Engineers

1

product decisions included

Data models

29

under Prisma, one schema

API modules

24

NestJS, one service

anonymous.architecture
Architecture diagram of Anonymous India: a Flutter app, Next.js site and admin console calling an Nginx front door, then a NestJS API on Fastify, with PostgreSQL, Redis and Firebase behind it.

Six decisions, and what each one cost

Every one of these has a downside. A case study that only lists the upsides is a brochure.

  1. No email, no phone, no password

    How do you run accounts on a product where anonymity is the point?

    Auth is a hashed device identifier and nothing else. The device generates a UUID, the server stores a SHA-256 hash of it, and that hash is the account. Tokens are JWTs signed RS256 with refresh tokens that can be invalidated the moment somebody is banned.

    The tradeoff

    Lose the device, lose the account. There is no recovery flow because there is nothing to recover against. That is a genuine cost, and I took it deliberately: a product about confessions should not be holding a table of phone numbers. The best way to not leak personal data is to never collect it.
  2. Fastify instead of Express under NestJS

    Why bother swapping the HTTP adapter?

    NestJS runs on Express by default. It also supports Fastify, which handles roughly twice the requests per second on the same hardware for JSON workloads like this one.

    The tradeoff

    A handful of Express-specific middlewares do not work, so anything reaching for req or res directly has to be written against Fastify. That is a real constraint on a solo project where nobody else has to learn the difference. For a team I would think harder about it.
  3. Redis carries the Socket.io adapter

    What happens to realtime when there is more than one API container?

    Socket.io keeps connections in memory. Two containers means two sets of connections that cannot see each other, so a reaction on one is invisible on the other. The Redis adapter puts a pub/sub layer between them so an event published anywhere is delivered everywhere.

    The tradeoff

    It is infrastructure I do not need today, on one container. I built it in anyway, because retrofitting it after you have scaled out is a migration and building it before is an afternoon. This is the same containment argument I make about ECS task definitions, applied to my own thing.
  4. Moderation runs on the server, not the client

    The profanity filter already existed in the Flutter app. Why move it?

    Anything enforced in the client is a suggestion. The filter, the rate limits, the reports, the bans and the audit log all moved server side. Rate limits are Redis backed and scoped per action, so posting, commenting and reacting each have their own budget.

    The tradeoff

    Every write now costs a Redis round trip and the app cannot give instant local feedback on a blocked word. Slower and less pleasant, and correct. A moderation system a determined user can disable by editing the client is not a moderation system.
  5. Counters live in transactions, not in application code

    Why is a comment count so hard?

    Comment counts, reaction counts and the rest are updated inside Prisma transactions alongside the write that caused them. Read, add one, write is a race the moment two people comment at the same time, and the numbers drift quietly for weeks before anyone notices.

    The tradeoff

    Longer transactions and more contention on hot rows. Worth it. A social product whose counts are visibly wrong loses trust faster than one that is briefly slower.
  6. Multi-stage Docker, non-root, four services

    How does it actually run?

    A three stage build on node:20-alpine: dependencies, builder, runner. The final image drops to a non-root user and carries no build toolchain. Production composes four services: Postgres 16, Redis 7, the API, and Nginx in front doing TLS and rate limiting at the edge.

    The tradeoff

    One host, so it is rung one on my own ladder, and that is on purpose. This product does not have the traffic to justify a cluster, and putting it on one to look impressive would contradict everything else I have written.

Honestly

What I would do differently.

  • The Flutter app talks to Firebase directly for storage and to my API for everything else. Two sources of truth for one product is a seam, and seams are where things break. I would put all of it behind the API.
  • 29 models is more than this product needed at the start. Subscriptions, referrals and feature flags all landed before anybody was asking for them. Building for a scale you have not reached is the exact mistake I write about.
  • I did not write a runbook until months in, because it was mine and I thought I would remember. I did not remember.

Owning one layer teaches you the layer. Owning all of them teaches you the seams, and the seams are where the expensive problems live.

The whole stack

Mobile
  • Flutter
  • Dart
  • Firebase Auth
  • Crashlytics
  • Analytics
  • RevenueCat
  • AdMob
API
  • NestJS 10
  • Fastify
  • Prisma 5
  • Swagger
  • JWT RS256
  • Socket.io
Data
  • PostgreSQL 16
  • Redis 7
  • Firebase Storage
Infrastructure
  • Docker
  • Docker Compose
  • Nginx
  • node:20-alpine
Web
  • Next.js
  • React
  • TanStack Query
  • Tailwind

Want to go through it properly?

I will walk through the schema, the auth flow or the deployment, whichever is more useful. Including the parts I got wrong.