Changelog
The honest story behind the builds, the breaks, and the late nights.
How it started: the idea
This didn't start as a plan. It started as one of those late-night rabbit holes — the kind where you're supposed to go to sleep, but instead you keep asking slightly better questions.
We went down a rabbit hole together through a ridiculous number of ideas. Some were borderline genius, most were absolute garbage: AI copilots for everyday decisions. A "life debugger." A tool to simulate alternate career paths. Even a half-serious attempt at building something like a personal "Jarvis" running locally. At one point we were sketching out a CMS to dethrone overpriced enterprise tools. Another time we were convinced the real opportunity was in adult-adjacent AI tools. It was chaos — but useful chaos.
One combination stuck: face recognition, and a fun conversation with a friend about how everyone has a pornstar twin.
Technical research: figuring out what was even possible
Mapped the core idea against what current models and frameworks could actually do. Built a few quick prototypes to test whether face recognition could run in the browser — no server, no upload, nothing.
The answer was yes. And that was more than enough to know something had to be built with it.
Day one: first commit
A Nuxt scaffold, a blank page, and a question nobody had cleanly answered yet. Could a browser — just a browser — figure out who someone looks like?
- Nuxt 3 project initialized
- Basic page structure: index and scan views
- File selection UI wired up with PrimeVue
Face matching in the browser
Shipped a fully client-side face recognition pipeline. A compact model, downloaded and run entirely in the user's browser — no server, no uploads. It was gloriously impractical and kind of magical.
- Loaded a face embedding model directly in the browser via ONNX Runtime Web
- Implemented L2 normalization and cosine similarity in JavaScript
- Built the comparison pipeline: face → embedding → nearest neighbours
- Wired up a Node backend for the gallery side of matching
Data pipeline: the real battle
The data fetcher ran for hours. Rate limits hit. Retries piled up. Some nights the machine just sat there, churning through performer profiles one by one, hoping the source wouldn't cut the connection. By the end there were thousands of images, a gallery of face embeddings — and a very tired laptop.
The whole performer index lives in memory at runtime. No database, no disk queries mid-request. Just a matrix of vectors and a lookup dict, loaded once at startup. Clean, fast, and honestly kind of satisfying.
- Built a scraper to fetch performer data and images from public sources
- Handled rate limiting with backoff and retry logic
- Preprocessed images without distortion before embedding
- Normalized gallery embeddings into a flat matrix for fast in-memory lookup
- Consent-first approach: only public performer profiles from opt-in databases
The domain
Doppelbänger.fun registered on GoDaddy. The umlaut in the name was a deliberate choice — a typographic nod to the German origin of the word, and an excuse to make the ä the most interesting character on the page.
- doppelbanger.fun registered
Moving the brain to Python, new design
The browser model worked. The proof of concept was done and genuinely impressive. But it was technically wrong for a product — who downloads a whole AI model to their phone for a five-minute laugh? My powerhouse of a phone was running hot. Moved the matching logic server-side. Suddenly things got fast.
- Replaced browser ONNX pipeline with a FastAPI Python backend
- Removed 200MB+ of ONNX runtime files from the client bundle
- Rebuilt the matching flow: upload → server embedding → cosine similarity → results
- Complete UI redesign with Tailwind — dark, moody, on-brand
- Space Grotesk + Inter font pairing
- Consent flow and local storage persistence added
A server to call its own
A DigitalOcean droplet spun up. $20/month. The whole stack — Caddy, FastAPI, Nuxt — running on a single box. Turns out the in-memory design scales surprisingly well: a hundred times the current traffic would still fit on one tier higher.
- DigitalOcean droplet provisioned
- Docker Compose setup with Caddy reverse proxy
- Gallery data deployed via Git LFS
Launched
First real deployment. Gender filtering, performer cards, analytics. The thing was live.
- Gender detection via lift scoring over top-k results
- GenderSelector component for filtering results
- PerformerCard component with similarity bar and profile link
- useTrackEvent composable abstracting analytics calls
- Umami analytics integrated for event tracking
- TheHeader and TheFooter components extracted
SEO foundation
First pass at making the site discoverable. Title tags, canonical links, robots.txt, sitemap, copy rewrite. The kind of work that feels invisible but matters.
- Title, description, lang, and canonical meta tags aligned
- robots.txt and sitemap.xml added
- Homepage copy rewritten for keyword relevance
- Footer with email address added
- www redirect configured
Infrastructure fixes and match tracking
Quiet but important work. Storing match results, fixing the dev proxy, making the deployment more stable.
- Match images and result data stored server-side after each search
- Docker volume fixed to persist output files across restarts
- Dev proxy corrected
- Sitemap and lang property fixes
- Prop typing cleaned up across components
Better event tracking
The analytics were there but not telling the full story. Added more granular events to understand what was actually happening per session.
- matching_started event added
- matching_failed event with error message payload
- Confidence threshold event for high-quality matches
Umami caused a DDoS. Ripped it out.
The self-hosted analytics instance started hammering the server — CPU spiked, the site went down. Killed tracking entirely while a replacement was found.
- Umami removed from all client pages
- useTrackEvent stubbed to no-ops to preserve call sites
- Docker Compose cleaned up
Google Analytics takes over
Switched to GA4. Proper event schema, no self-hosted liability, and gtag available everywhere.
- GA4 integrated
- IP anonymization enabled
- open_performer_profile event with performer name, score, gender, and position
- GA disabled in dev environment
Cross-link to Twinify
Added a referral link to a sister tool for celebrity lookalikes — for users who are more curious than thirsty.
- Twinify.co cross-link added to results panel
- UTM parameters set for referral tracking
Major refactor: smarter matching, hardened backend, cleaner code
The biggest single commit since launch. Z-score normalization replaced raw cosine similarity — genuinely similar faces now stand out instead of everything clustering in a narrow band. The backend got hardened. The frontend got split into components.
- Z-score normalization applied to similarity scores — better signal, fewer mediocre matches
- File type and size validation on uploads (25 MB limit, HEIC support added)
- HTTP 400/413 errors returned properly instead of silent 200s
- O(1) actor metadata lookup replacing a per-result linear scan
- Logger aligned to uvicorn for consistent Docker output
- Path traversal guard added to image serving endpoint
- app.vue split into UploadPanel and ConsentModal components
- Backend error messages surfaced to the user with a fun tone
Analytics, identity, and polish
GA4 funnel set up to track the only metric that matters: did someone find a match interesting enough to click? New logo, favicons, PWA manifest. The GenderSelector got rebuilt as a proper button group.
- GA4 Funnel Exploration: matching_started → open_performer_profile
- great_match event removed — profile clicks are the real signal
- Twinify link aligned visually to result cards, click tracked
- Custom SVG logo and favicon — the two dots are exactly what you think they are
- PNG favicons generated at all standard sizes
- PWA manifest — the site can now be installed to a home screen
- GenderSelector rebuilt as a segmented button group with live counts
- Gender order dynamic — the majority gender shown first