Můj první SaaS
Jak vznikal můj první SaaS projekt. Od prvního nápadu přes návrh vlastního proof-of-work algoritmu.
Jak vznikal captchaapi.eu - od německého klienta přes vlastní proof-of-work algoritmus až k týdnům strávených nad GDPR.
Pracoval jsem na zakázce pro dlouhodobého německého klienta, když se mě zeptal, jestli neznám nějaký rozumný CAPTCHA balíček pro Laravel. Automaticky jsem nabídl reCAPTCHA nebo Cloudflare Turnstile - to, co bych nasadil sám. Jenže on mi v klidu vysvětlil, proč to nejde: data jeho zákazníků nesmí opustit EU a o cookie banneru kvůli CAPTCHA vrstvě nechce ani slyšet.
Tak jsem začal hledat. Předpokládal jsem, že najdu desítky EU-based alternativ - vždyť GDPR je tu už šest let, někdo to musel vyřešit. Realita byla jiná. EU CAPTCHA služby existují, ale skoro všechny míří na enterprise klientelu s tomu odpovídající cenou. Pro freelancera, malou agenturu nebo pětičlenný startup, který chce nasadit CAPTCHA na contact form za rozumnou cenu, byla nabídka slabá.
V tu chvíli mě to trklo:
Tohle by mohla být díra na trhu.
A tak jsem se rozhodl vytvořit vlastní CAPTCHA SaaS.
Co jsem věděl a co ne
Nevěděl jsem skoro nic o GDPR. Nevěděl jsem, co je Schrems II ani co znamená Article 28. Nevěděl jsem, jak se píše DPA. Co jsem uměl: Laravel, PostgreSQL, Redis, a že umím postavit základy projektu, které budou funkční.
To byl výchozí stav: solidní technická základna a chuť prokousat se compliance vrstvou. Že zabere skoro stejně času jako samotný kód, jsem zjistil až později.
Volba algoritmu
První rozhodnutí: jaký typ CAPTCHA postavit. Obrázkové puzzle (vyber všechny semafory) jsem zavrhl rychle. Z vlastní zkušenosti vím, jaký je to opruz klikat na rozmazané obrázky a hádat, jestli sloup s lampou je semafor nebo ne. Behaviorální ML scoring jsem zavrhl taky, jenže z jiného důvodu: abych ho rozjel, musel bych sbírat data o zákazníkových klientech - přesně to, čemu jsem se chtěl od začátku vyhnout.
Zbyl proof-of-work. Klient něco spočítá, server ověří výsledek, hotovo. Bez cookies, bez fingerprintingu, bez cross-site dat. Implementačně jednoduché, GDPR-clean by design.
První iterace byla naivní: hledej hash, který začíná určitým počtem nul. Funguje to. Překvapilo mě, jak málo kódu na to stačí. PHP hash('sha256', $input) v cyklu, kontrola začátku stringu, hotovo.
Pak přišel problém se škálováním obtížnosti.
Proč počítání nul nefunguje
Logika "čím víc nul, tím těžší" zní rozumně, jenže matematika je nepříjemná: každá další povinná nula zmenší prostor možných řešení šestnáctkrát (každá hex pozice má 16 hodnot, nula je jen jedna z nich). Obtížnost neroste lineárně, ale exponenciálně.
V praxi to vypadalo takhle: 4 nuly se najdou prakticky okamžitě. 5 nul trvá pár sekund. 6 nul trvá desítky sekund. A to na M-series MacBooku.
Neumím si představit, co by takový algoritmus dělal na pětiletém Androidu s pomalým CPU. Návštěvník by čekal minutu, než se mu odešle kontaktní formulář. Jako CAPTCHA pro reálné weby? Nepoužitelné.
Potřeboval jsem plynulé škálování, ne exponenciální.
Druhá iterace: numerický target
Řešení přišlo z bitcoinového miningu, kde stejný problém řeší už 15 let. Místo počítání nul se porovnává numerická hodnota prvních pár bytů hashe proti cílové hodnotě (target).
Server vydá:
- Náhodný 32B token (stored v Redis cache spolu s hashem IP adresy, TTL 2 minuty)
- Target - 32-bit integer
Klient hledá takovou hodnotu solution, aby platilo:
hexdec(substr(sha256(token + solution), 0, 8)) <= target
Čím nižší target, tím méně hashů projde - tím těžší výpočet. Klíčový rozdíl: target může být libovolné číslo mezi 1 a 2³²-1. Nepřidávám nuly po skoku, ale plynule snižuju číselnou hodnotu. Obtížnost teď škáluje spojitě, ne v exponenciálních stupních.
V praxi to znamená:
- Free tier: target
0x000FFFFFaž0x0000FFFF(4 096-65 536 očekávaných hashů) - Business tier: target
0x003FFFFFaž0x0003FFFF(1 024-16 384 hashů, čtyřikrát rychlejší)
Free tier visitor zaplatí pár desítek milisekund CPU práce ve Web Workeru. Business tier visitor něco přes deset milisekund. Botnet, který chce poslat milion submitů, zaplatí tu samou cenu milionkrát - a o to přesně jde.
Z challenge-verify na lokální HMAC verify
Původně jsem fungoval klasicky: server vydá challenge, klient pošle solution, server ověří a vrátí "OK/not-OK". Tři round-tripy na každé odeslání formuláře a při větší zátěži potenciální úzké hrdlo.
Dnes je flow jiný:
- Widget (JS na klientovi) vydá challenge a verifikuje solution proti naší API
- API vrátí podepsaný attestation token -
base64url(payload).base64url(HMAC-SHA256(payload, secret)) - Backend integrátora dostane attestation v hidden inputu při form submitu, sám si HMAC ověří lokálně pomocí secret klíče
Backend integrátora nikdy nekontaktuje captchaapi.eu - celá verifikace běží lokálně, je to čistá kryptografická operace. Žádný HTTP roundtrip. Sub-millisekundové ověření.
Token formátem připomíná zjednodušený JWT: má payload a podpis, oddělené tečkou. Ale není to JWT - chybí header, chybí alg field, je to vlastní lightweight formát postavený na minimum potřebných polí. Pro ten use case (single-purpose attestation s krátkým TTL) by JWT header byl zbytečná režie.
Rychlost a paralelní zátěž
Druhý strašák po obtížnosti: rychlost endpointu pro vydávání challenge. Když někdo spustí paralelní útok na ten endpoint, můžu přijít o produkt dřív, než se vůbec rozjede.
První profilování ukázalo databázi jako bottleneck - každý request dělal několik SQL queries, abychom dohledali project, ověřili ho, zaznamenali request. Postupně jsem všechno přesunul do Redis cache: project metadata, rate limiting per-IP, challenge state. Databáze v hot pathu vidí dnes jeden SQL dotaz - increment denního counteru pro quota tracking. Zbytek je Redis.
Latence klesla z desítek milisekund na jednotky. Pokud se zátěž zvedne, vertikálně škáluju Hetzner instance - pro single-region EU service je to výrazně jednodušší než distribuované řešení.
Pak přišly legals
Programování byla ta zábavná část. Chrlil jsem kód, viděl výsledky, refaktoroval. Měl jsem MVP, který fungoval.
A pak jsem otevřel editor a začal psát privacy policy.
Tady přišla úplně jiná disciplína. Začal jsem GDPR Article 28 (povinnosti zpracovatele), pokračoval Article 13 (informační povinnost), ztratil se v Article 4(4) (definice profilingu), narazil na Schrems II (CJEU C-311/18), zjistil, že existuje EU-U.S. Data Privacy Framework, který je teď napadený před EU General Court (case T-553/23) - a došlo mi, že tahle vrstva je možná důležitější než samotný kód.
Konkrétní otázky, na které jsem narážel:
- Jak přesně formulovat legitimní zájem v privacy policy? (Article 13 vyžaduje specific legitimate interest, ne generic "for security")
- Co znamená sub-processor versus independent controller? (Lemon Squeezy jako Merchant of Record je technicky druhé, ne první)
- Jak dlouhá má být retenční perioda pro IP hashes? (Skončil jsem na 2 minutách v Redis - minimum nutné pro rate limiting, nic víc)
- Jaký transfer mechanism použít pro Stripe? (DPF certifikace + Standard Contractual Clauses jako fallback)
Strávil jsem několik týdnů čtením, ne psaním. EU právní stránky, právnické blogy, Schrems II text doslova (90 stránek), GDPR enforcement decisions, srovnání jak jiné EU SaaS firmy formulují své DPA.
Ten výsledek - Article 28 DPA s podpisovým blokem, sub-processors page s konkrétními certifikacemi a daty audit reportů, retention table v privacy policy s milisekundami u IP hashes - si dnes cením víc než samotného kódu. Compliance-as-code místo compliance-as-PDF. V EU SaaS scéně je to podivně vzácné a paradoxně je to dnes moje největší konkurenční výhoda.
Co jsem se naučil
Pár věcí, které bych řekl sám sobě před třemi měsíci:
1. Compliance není overhead, je to feature. Pokud cílíš EU privacy-conscious zákazníky, tvoje DPA, sub-processors page a retention table jsou marketingové aktivum, ne nutná zátěž. Většina konkurence je má boilerplate; tvoje můžou být lepší.
2. PoW algoritmus je triviální. Difficulty scaling je netrivální. Hodiny, které jsem strávil refactoringem z "počítej nuly" na "numerický target", byly investice, která se vrátila každým dalším řádkem kódu. Špatný algoritmus se nedá zachránit dobrou implementací.
3. Single-region EU deployment je super-power, ne limitace. Pokud nemusím řešit cross-region replication, mám jednodušší kód, jasnější compliance story, predictable performance. Hetzner Nuremberg, jeden datacenter, konec.
4. Stateless local HMAC verify je load-bearing rozhodnutí. Bez něj by produkt potřeboval centrální verify endpoint, stoupla by latence, zhoršila by se závislost na uptime a privacy claim ("nelogujeme vaše requesty") by se zhroutil. Jedno design rozhodnutí, které drží celý zbytek.
5. Psát legals je horší než psát kód, ale stálo to za to. Investice se vrátila ve formě materiálu, který mi dnes slouží jako trust signal pro každého procurement reviewera. Bez něj bych neměl produkt - měl bych jen API.
Tech stack
- Laravel 13 - backend
- Livewire 4 - dashboard a interaktivita
- FluxUI Pro - komponenty
- Tailwind CSS 4 - styling
- PostgreSQL 18 - perzistence (account data, projekty, billing)
- Redis - challenge state, rate limiting, quota counters
- Hetzner Nuremberg - hosting (single region, EU only)
Žádný Kubernetes, žádné microservices, žádná multi-region magie. PHP/Laravel + Redis + Postgres na jediném Hetzner serveru.
Projekt nepoužívá žádnou non-EU službu kromě platební brány (Stripe Payments Europe Ltd. - Ireland, s ancillary US transfers pod DPF + SCCs) a MoR řešení pro consumer payments (Lemon Squeezy LLC - US, jako independent controller pod DPF + SCCs Module 1). Obě jsou explicitně dokumentované na sub-processors page.
Co dál
Aktuálně dolaďuji distribuci a spouštím launch. captchaapi.eu má free tier (5 000 challenges/měsíc), placené plány od €9/měsíc. Widget je open-source - minified, ne obfuskovaný, můžeš si přečíst, co přesně běží v browseru tvých klientů.
Pokud cílíš EU zákazníky a hledáš CAPTCHA bez cookies, podívej se. Pokud máš zpětnou vazbu - od bug reportů přes architektonické připomínky až po "tohle je špatně formulované" - pošli mi e-mail. Čtu si je sám.
A pokud zvažuješ stavět vlastní SaaS jako solo developer: stojí to za to. Ale počítej s tím, že polovina práce je něco jiného než programování, a tu polovinu nemůžeš obejít.