Amour
Amour is our product, not a client's. We designed it, built it, and run it in production, which means every decision below was ours to make and ours to live with. It is live on Android.
The brief
Amour needed to work under a content policy we set ourselves: every photo and every first message reviewed before another user sees it. That requirement is easy to write down and it is the single decision that shapes the whole backend, because moderation is not optional and it is not fast.
The constraint
An image classifier plus a human review queue has a median in the hundreds of milliseconds and a tail measured in minutes, because a person is in the loop for anything ambiguous. Any design that makes the sender wait for that verdict is a design where the app is sometimes unusable — and a chat app that feels broken is a chat app people delete.
So we inverted it. The send path writes the message as pending and returns. The sender sees their own message straight away. The recipient does not see it until moderation clears. If it fails, the sender is told and the message is marked as blocked in their own thread, rather than silently disappearing.
Moderation runs off RabbitMQ: image checks through AWS Rekognition, text through AWS Comprehend, both asynchronous, neither in the send path.
What it costs us. A message can appear and then be pulled. We had to build retraction into the client, into the read model, and into the notification path, and then explain to ourselves why a message that was clearly sent is now gone. A synchronous design would never have needed any of that.
Why four datastores
PostgreSQL, reached through Prisma, is the source of truth. Neo4j holds the matching and relationship graph. DragonflyDB caches. RabbitMQ carries asynchronous work between services.
The argument for Neo4j is that mutual-interest lookups — people who liked you, whom you also liked, minus everyone either of you has blocked — are the query the product is built around, and they are recursive. Written against relational tables they become CTEs nobody enjoys reading.
What it costs us. Two stateful systems that can disagree. Postgres is authoritative and the graph is derived, so a divergence is recoverable, but “recoverable” means somebody wrote the reconciliation and somebody tested the restore. See the first entry under what we would do differently — this is the decision we are least sure of.
Media and safety
Photos upload straight to Cloudflare R2 with a presigned URL, so the API never handles the bytes. A worker pulls the original, generates the sizes the app needs, runs the classifier, and only then writes the public keys.
R2 rather than S3 for the egress pricing, which for a media-heavy consumer app is the line item that grows fastest.
What it costs us. A profile photo is visible to its owner immediately and to everyone else once the pipeline finishes. That reads as a bug to users who do not know the policy exists. We decided a visible delay was better than a moderation gap.
Infrastructure
K3s on Hetzner, with deployments managed declaratively through Flux. Identity runs through Logto, subscriptions and entitlements through RevenueCat. Chat and activity feeds use GetStream; push goes through Firebase; Agora carries real-time messaging with offline queuing on the device.
We chose self-hosted Kubernetes over managed cloud to keep hosting an order of magnitude cheaper without giving up reproducible deploys. That trade is only available to a team willing to be the on-call for the cluster itself.
What it costs us. We are that on-call. It is fine while we run our own product. It is a real consideration for any client weighing the same choice, and it is why we say plainly that if nobody on your side wants to own a cluster, managed is cheaper in total even when the invoice is larger.
The stack
What we would do differently
A photo moderation rule ran on only one of our two paths.
Every photo goes to AWS Rekognition. Quick answers are decided inline within two seconds; slow ones fall back to a background worker. The gender-mismatch rule only ran on the background path, so a photo Rekognition read as male at 99.58% confidence was approved on a woman's profile, and enforcement came down to AWS latency. Every path now runs every rule, and a mismatch holds the whole account for review. When a check has a fast path and a slow path, we test the rules on both.
Twelve discovery filters did nothing, and nothing errored.
Filter matching was gated by a hardcoded list of keys, so smoking, drinking, religion, education, want-kids, relationship intent and six more filters were silently dropped. Distance only changed the ranking, so people outside the chosen radius still appeared, just lower down. The results looked plausible, which is why it survived until release testing. Filters now run off their type rather than a list, and distance is a hard cutoff. We test a filter by what it removes, not by whether results come back.
Pull-request builds could deploy themselves to production.
CI tagged every pull-request image as latest and master, and Flux deployed whatever carried those tags, so each update to an open PR overwrote the production image. Now only a push to master produces a deployable tag; pull-request builds get commit tags and nothing else. In any GitOps setup, the tag the cluster watches should be writable by exactly one branch.
We put this section on every case study. If a write-up has no version of it, either the project was trivial or the write-up is an advertisement.