~/henry-cabello
CVEspañol
← back to ./work
Live in production

Luxura

Invitation-based dating app, Bogotá and Medellín. Eleven weeks from an empty repo to Google Play. I built it, I run its server, I paid for its ads. Every number below came out of the database it is still running on.

Role
Founder, technical lead: product, backend, infrastructure, growth
Design
Victor ↗
Build window
2026-01-21 → 2026-04-06
Runs on
One Hetzner box, Coolify, Docker

Google Play · 5,000+ downloads ↗luxura.vip ↗

Luxura profile creation form, step one of onboardingLuxura browse grid showing verified profiles and the daily invite counterLuxura connections screen listing accepted matches
Store listing, Spanish. Onboarding, the browse grid with the daily invite counter, and the connections screen.

$ cat ORIGIN.md

It started as an observation, and a friend who cared how it looked.

Dating apps were missing a lot. The experience felt cheap and the incentives were wrong. But a few apps had already validated the better model, where one side invites and the other side chooses, so the idea was not the risk. The execution was.

I asked myself why I could not build a better one. It began as a small project and turned into something I could not put down. Victor designed it with me, screen by screen: the login, the profile cards, the landing pages, the dark and gold direction the whole thing still runs on. We went slowly on purpose. The goal was never the most features. It was something that felt good to use and looked like somebody had cared.

$ cat platform.md

A website was fine. Most users wanted an app.

Capacitor made the web app and the Android app one artifact: one codebase, one deploy, as long as I stayed disciplined about responsive layout and wired the native plugins for push, GPS and the camera myself.

Then came the questions nobody warns you about. Publishing to Google Play takes weeks. It is two weeks of mandatory closed testing with twelve testers before you are allowed near production. Payments were another: RevenueCat for store subscriptions, and no web payments at all in the first release, because one billing surface was enough to get wrong. iOS I deferred and never shipped. The Capacitor target is in the repo. Android is where this market is.

One consequence is still my favorite thing about the build. The shell loads the web app live from my server, so a JavaScript change reaches every installed phone on the next deploy. A store release is only needed when native code changes.

$ cat README.md

Open dating apps drown in low-effort volume.

Everyone sends, nobody chooses, and the people receiving leave first. Luxura inverts it. One side browses and spends a strictly limited number of invitations. The other side receives them and chooses. Scarcity on the sending side is the product. An invitation costs the sender something, so it means something to the receiver.

Other apps had already validated the model, so the execution was the part I wanted. It started as a project with a friend and turned into a product with an ad account attached to it.

# Constraints

  • Colombian market. Price ceiling around $5/month, not $15.
  • Android-first, mid-range phones, mobile data. Bundle size decided what shipped.
  • Solo budget. No vendor above tens of dollars a month.
  • Spanish and English at parity from day one, 1,911 keys each.

$ ./bin/report --totals

3,709Signups
1,724Approved
3,983Invitations
888Connections
15,480Messages
2Paying

$ ./bin/report --ads-off 2026-03-08

I paused paid acquisition for one day to find out what I had actually built.

Everything looks healthy while the ads run, which is exactly when you cannot tell demand from spend. So I turned the tap off for a day. The more product-like the metric, the less it moved.

Top-of-funnel was almost entirely bought. The 48 signups that still arrived with the ads off are what organic discovery looked like. But the people already inside kept talking. Chat was three days old and was already the stickiest thing in the product. That comparison set the roadmap for the next two weeks. I also pulled a retention table that week and will not use it here: it measures a recency window, so it flatters whoever signed up last, and the ads-off day is the cleaner comparison because both cohorts were measured on the same day.

$ cat sdd/proposals/role-based-app-overhaul.proposal.md

Half the traffic I was paying for died before it saw another human being.

Production was sound and everything was tested. Then the ad account opened and the money started evaporating at the door. Registration ran phone, then profile, then photos, then a live face check. Only then did you see a single profile.

I did not patch it on instinct. I measured, wrote the diagnosis down, and shipped the fix.

## Motivation (Why)

The current onboarding has a 47% completion rate (846 users stuck at
verification). Gender is hardcoded to role (MALE=inviter, FEMALE=invitee),
which blocks same-sex dynamics.

**Core insight**: Let users see the candy shop immediately, but gate
interaction behind profile completion and payment.

Verbatim from the proposal, dated 2026-03-09. It shipped two days later as one change: register, pick a role, pick an orientation, and you are in. Inviters get the full browse feed immediately. Invitees get a labelled demo inbox populated with real profiles. The three-step wall still exists. It just does not rise until you try to invite or accept. The gate moved from the entrance to the interaction.

$ psql -c "verification completion, before vs after"

SELECT CASE WHEN u."createdAt" < '2026-03-11' THEN 'before' ELSE 'after' END AS era,
       count(*) AS with_profile,
       count(*) FILTER (WHERE u."onboardingStep" IN ('VERIFIED','ACTIVE')) AS verified,
       round(100.0 * count(*) FILTER (WHERE u."onboardingStep" IN ('VERIFIED','ACTIVE')) / count(*), 1) AS pct
FROM users u JOIN profiles p ON p."userId" = u.id GROUP BY 1;

  era    | with_profile | verified |  pct
---------+--------------+----------+-------
 before  |         1994 |     1027 |  51.5
 after   |         1057 |      678 |  64.1
(2 rows)

Run against the live database in August 2026. Across all signups the lift is smaller, 44.3% to 48.7%, because the change also let in a large group who never started a profile at all. Week by week: the worst ad-driven week verified 40.4%; by 23 March that reached 56.8%.

The wall moved rather than disappeared.

1,239 users now sit at the first step, inside the app, never having built a profile. That is a real leak and it is the one I would have attacked next. It is still the better leak to have: those people saw the product before they decided, which was the entire point.

$ ls -la ./engineering

Traded a 10MB WASM bundle for a 1.4MB WebGL one, and kept both engines behind a flag

face-api.js wanted roughly 10MB of models. On a mid-range Android phone over Colombian mobile data that is a dead product before it renders. Jeeliz WebGL does the gesture work at about 1.4MB. Human.js stayed as a second engine behind an environment flag, and in auto mode it loads first and drops back to face-api.js without a visible failure, because one implementation means one point of failure, and a user who cannot verify is a user who leaves.

// content moderation: resolve in order, first success wins
1. self-hosted    NudeNet + InsightFace + OpenCV   // safety AND quality
2. Azure          Content Safety ‖ Computer Vision // in parallel
3. Google         Cloud Vision                     // one call, both
4. Sightengine    nudity · offensive · type · face

// flagged no_face | screenshot → a small vision model re-judges THAT flag
// video → frames pulled and judged one at a time
// rejected → stored as a perceptual dHash, re-upload caught for free
Made the expensive services argue with a cheap one instead of trusting them

The big vendors cry wolf, and a false rejection costs a real user who is already halfway out the door. So a flagged photo gets its specific flag re-judged by a small vision model, independently, and only an all-clear across every flag upgrades it. The cheapest piece is my favorite: every rejected photo is kept as a perceptual hash, so a re-upload of the same image gets caught instantly, with no vendor call at all. People re-upload constantly.

Put FFmpeg in the Docker image instead of renting a transcoding service

Images compress in the browser at a 500KB / 1920px cap, so 80 to 90% of the bytes never leave the phone. Video compresses server-side in a background job (H.264, 720p, CRF 23) with a processing flag on the row so the UI can show state while it runs. Roughly 87% storage reduction for no marginal cost beyond CPU I was already paying for. When you pay for your own ads, every recurring vendor line comes out of the same budget.

Took a custom Node server, and with it a permanent exit from serverless

Socket.IO attaches to an HTTP server that wraps the Next.js request handler, which is why this app starts with a custom entry point and not next start. That closes a real door. No serverless host will run this again. It was the right trade because the alternative was polling, and because I already ran my own box. 424 messages by the next morning, 71.4% of connections chatting, and 15,480 messages today.

// phone OTP, eight weeks
raw Twilio  Twilio Verify  raw Twilio Messaging  Firebase Phone Auth
   Plivo Verify  Twilio + own otp_codes table  Infobip + Twilio fallback

// three of those swaps happened on a single day: 2026-03-14
Stopped shopping for a vendor and built the seam instead

Every move came from Colombian delivery rates, per-message cost, or an error surface I could not control. One Twilio error code alone flooded Sentry with what were really user typos. Nothing in the app matters if the first screen leaks users, so I stopped treating it as a procurement problem. The code now owns the codes, the rate limiting and a circuit breaker, and the provider behind it is a config value. Once that seam existed, changing primary took a day instead of a sprint. It is where I should have started.

$ cat economics.md

I paid for the ads myself, so every decision came back with a price per signup attached.

$800Total ad spend
$0.33Per signup
$0.70Per approved user
0.08%Paid conversion

The spend and the per-unit figures cover the paid-acquisition period to 9 March, when there were 2,451 signups. The totals at the top of this page are lifetime, to August.

Eight hundred dollars showed me where the funnel leaked. The subscription launched at $15/month into a market that supports about $5. Weekly plans at roughly $2.50 shipped on 17 March, a month after the money was already committed.

Every user in the March report is tagged source: unknown

I specified attribution properly on the next project, before any spend, because of this exact line. Paid acquisition without attribution left me guessing: I can tell you what $800 bought in aggregate and I cannot tell you which campaign did it.

$ cat scope.json

1,326Commits
177API routes
77Pages
44Prisma models
75Migrations
37Cron/event jobs
26Admin sections

Plus: CI running lint, typecheck and build on every push · Sentry across client, server and edge with a tracked bug log · deep links and store asset-link files · rewarded ads through AdMob · RevenueCat subscriptions with a nightly reconciliation job · a Playwright suite across onboarding, browse, invitations, verification and admin · and a growth stack that auto-publishes to X, Threads and Facebook with canvas-rendered quote images.

$ cat method.md

1,326 commits in eleven weeks, alongside the ad account.

That came from an agentic workflow, and more from the guardrails that make one survivable.

  • A pre-ship checklist where every item maps to a real incident. Item 4 exists because a function passed from a Server Component to a Client Component took the site down on 19 March.
  • Spec-driven development for anything crossing more than one subsystem: proposal, spec, numbered tasks, all committed. The funnel rebuild shipped that way.
  • A blind second-model review before every push, run by a non-Anthropic model. The monia teardown has the measurement that justifies it.
  • CI that will not let a type error reach the server, because one did.

The volume matters less than what it was spent on. What changed were the failure modes. Fewer typos and forgotten null checks; far more correct module, never wired up at the composition root. The checklist is aimed squarely at those.

$ ls ./ad-creative

I made the ads too, and that is where the next product came from.

Nobody was going to hand me creative. I generated the video in Kling, using motion control to get the camera moves I wanted. For the voiceovers I recorded a take and then swapped the voice with Cartesia or ElevenLabs until the register sounded like Bogotá rather than a translation. Then I posted. Instagram, Facebook, several profiles, every day, by hand, on top of the build. Generating a clip took minutes, and tending the output took the rest of the day.

That daily tending is what produced my next product, which has its own page.

Luxura ad for the men audience: a woman on a Bogotá street at dusk under the app's online badgeLuxura ad for the women audience: the invitation inbox showing three pending invitations and their expiry countdownLuxura ad for the LGBT audience: two men laughing together in a bar
Three of the sets that ran. One per audience, same template, each in light and dark, static and video, across fullscreen, banner, story and inbox formats. The filenames carry the segment, which is how I could tell later which cut was doing the work.

$ cat POSTMORTEM.md

It did not take off, and it pointed at the next thing.

Luxura is live and people use it. The product side held. The day I paused ads, signups fell 83% and messages fell 11%. What I could not sustain was the spend that keeps the top of the funnel alive, at two paying subscribers against $800.

The ad work above is what changed the direction. Somewhere in the daily tending of those accounts I asked why I was doing it by hand, and friends running small shops turned out to have the same problem from the other side, drowning in messages they could not answer. That is where Storía came from. I built it in four days that February, in the middle of this.

# What I would do differently

  1. Let people in before asking them to prove anything. I learned this by buying it. Fixing it moved verification completion among profile-starters from 51.5% to 64.1%, after the ad spend, not before.
  2. Ship the conversation on day one. Chat was the only feature retention responded to, and it arrived in week seven. Everything before it was a matching engine that handed people to WhatsApp.
  3. Own the commodity vendors from the start. Four SMS providers across seven configurations, and four moderation providers, taught me the same lesson twice: keep the state in my database, make the vendor a config value.
  4. Price for the market before buying traffic. $15/month into a $5/month market wasted most of the $800. The weekly plan that fits Colombia shipped a month late.
  5. Never encode a demographic assumption in the schema. Gender sat in the routes, the copy and the tables at once, so replacing it with a role cost a week instead of a day.
  6. Buy attribution before buying ads. source: unknown, 2,026 rows.

← back to ./work