Warning
Vibe-coded end to end. Not a single line of this repo was read by a human.
The whole thing — Next.js app, DB schema, migrations, passkey ceremony, PWA service worker, PDF exporter, Zerops recipe, this README — was generated by an AI agent in one sitting from a plain-language description. Nobody reviewed the code. Nobody sanity-checked the SQL. Nobody looked at the auth flow the way an auth flow deserves to be looked at.
It's AI code and it looks the part. Expect quirks, uneven abstraction levels, over-engineered corners next to under-engineered ones, and the occasional load-bearing hack. Don't run it for anyone whose health decisions depend on it. Don't put strangers' medical data in it.
Want to make it better? Fork it and keep vibing — prompt the model of your choice, ship your own version. PRs welcome too, but no promises anyone here has the context to review them properly.
Tlak is a self-hostable, multi-tenant web app for tracking blood pressure, pulse, weight and lifestyle context over time. Passkey-only authentication (no passwords), doctor-ready PDF export, PWA with push reminders, ESH 2023 classification, Czech + English UI. Built with Next.js 15, Drizzle ORM and PostgreSQL. MIT-licensed.
⬇️ Deploy your own instance on Zerops
Import import.yaml from this repository into a new Zerops project
(dashboard → Import project → paste). Or pick a specific environment shape from
recipe/:
- development — single-node Postgres, dev-tier scaling — for personal / self-hosted use
- production — HA Postgres, multi-container app, autoscaling — for public deployments
- 📈 Charts & trends — Recharts with ESH 2023 hypertension categories (optimal / normal / high-normal / grade 1–3 / isolated systolic)
- 🩺 Doctor-ready PDF export — printable summary + full log for the selected period
- 🔐 Passkey / WebAuthn only — Face ID, Touch ID, Windows Hello. Zero passwords, zero SMTP dependency
- 📱 PWA — installable to home screen, push notifications for morning + evening measurement reminders and weekly weight reminder
- 🌍 i18n — Czech + English, per-user preference
- 🌓 Light / dark / system theme
- 🏋️ Weight + BMI tracking with configurable height
- 📝 Contextual notes — arm, position, medication taken, lifestyle checkboxes (poor sleep / stress / exercise / caffeine / alcohol / salty meal)
- 🔒 Multi-tenant — every user's data is isolated via
user_idforeign keys
| Layer | Tool |
|---|---|
| Framework | Next.js 15 (App Router, standalone output) |
| Language | TypeScript |
| Styling | Tailwind CSS v4 |
| Auth | Auth.js v5 (Credentials + @simplewebauthn/server) — passkey ceremony |
| Database | PostgreSQL (16+) via postgres-js |
| ORM | Drizzle ORM + drizzle-kit migrations |
| i18n | next-intl |
| Theme | next-themes |
| Charts | Recharts |
| @react-pdf/renderer (server-rendered) | |
| Push | Web Push API + web-push (VAPID) |
| Validation | Zod |
The zerops.yaml at the repository root drives the build + deploy pipeline.
Two setups are provided:
prod— production-style: fullnext buildproducing the standalone bundle, migrations applied once per deploy viazsc execOnce, app runsnode .next/standalone/server.jsdev— dev mode: source deployed as-is, idle container waiting fornext devstarted by the AI agent workflow (zerops_dev_server)
Set these on the runtime service (dashboard → Environment variables, or via
zerops.yaml run.envVariables). The import.yaml in this repo pre-seeds the ones that need
to be auto-generated; the rest you set once after import.
| Variable | Purpose | Auto-generated by import.yaml |
|---|---|---|
AUTH_SECRET |
Auth.js JWT signing secret (64 random chars) | ✅ |
AUTH_URL |
Public URL of the runtime (needed for correct redirect after sign-out) — set after first deploy | ❌ |
AUTH_TRUST_HOST |
true — trust the Host header (needed behind Zerops's L7 balancer) |
✅ |
WEBAUTHN_RP_ID |
Domain (no scheme, no port) — must exactly match what browsers see. Passkeys are bound to this Relying Party ID | ❌ |
WEBAUTHN_ORIGIN |
https:// + domain — comma-separated list if you serve on multiple origins |
❌ |
WEBAUTHN_RP_NAME |
Display name shown by the OS during passkey ceremony ("Tlak") |
✅ |
VAPID_PUBLIC_KEY |
Web-push public key — generate with npx web-push generate-vapid-keys |
❌ |
VAPID_PRIVATE_KEY |
Web-push private key | ❌ |
VAPID_SUBJECT |
mailto:you@example.com — contact for push service |
❌ |
CRON_SECRET |
Bearer token protecting /api/cron/reminders |
✅ |
DB_HOST, DB_PORT, DB_USER, DB_PASS, DB_NAME |
Wired via ${db_*} references — no manual setup |
✅ (via import.yaml) |
- Enable a subdomain on the
tlakservice (Zerops dashboard → Public access → Enablezerops.appsubdomain), or attach a custom domain. - Set
AUTH_URL,WEBAUTHN_RP_ID,WEBAUTHN_ORIGINto match that domain. - Generate VAPID keys locally with
npx web-push generate-vapid-keysand set the threeVAPID_*variables. - Restart the runtime (env changes trigger this automatically).
- Open the URL, register your passkey. Done.
⚠️ Passkeys are bound to the domain. ChangingWEBAUTHN_RP_IDinvalidates existing passkeys — users need to re-register. Pick your final domain before onboarding real users.
The endpoint /api/cron/reminders iterates all users and delivers push notifications based
on their per-user schedule (morning / evening measurement, Sunday weight). It's protected by
CRON_SECRET:
GET /api/cron/reminders?token=<CRON_SECRET>
Wire this to Zerops's cron (or any external scheduler) to fire hourly.
Auth.js v5 in this app uses two Credentials providers wrapping @simplewebauthn/server:
webauthn-register— receives the WebAuthn attestation from the browser, verifies it against a challenge stored in thewebauthn_challengetable, creates the user +passkeyrow, returns a JWT sessionwebauthn-login— receives the assertion, looks up the credential by ID, verifies, bumps the counter, returns a JWT session
No Auth.js Adapter is used — session strategy is JWT, users table is the app's own store.
Migrations are generated by drizzle-kit (npm run db:generate) into drizzle/. On deploy,
scripts/bundle-migrate.mjs bundles src/db/migrate.ts + postgres-js + the SQL files
into a self-contained migrate.cjs via esbuild. zerops.yaml runs
zsc execOnce ${appVersionId} -- node migrate.cjs which guarantees the migration runs
exactly once per deployed version even with multiple replicas.
All user-owned tables (measurement, weight_entry, push_subscription, passkey) carry
a user_id foreign key with ON DELETE CASCADE. Every server action + query filters by
session.user.id. No row-level security in Postgres — enforced entirely in the app layer.
public/sw.js is a minimal service worker (no offline caching yet, just push handling).
public/manifest.webmanifest marks the app as installable. iOS Safari 16.4+ only delivers
web push when the app is installed to the home screen — an important UX note to communicate
to users.
npm install
# Create .env from .env.example, fill in DB + WEBAUTHN_* values
npm run db:push # sync schema to a local Postgres
npm run vapid:generate # emit VAPID_PUBLIC_KEY / VAPID_PRIVATE_KEY
npm run devMIT — see LICENSE.
Contributions welcome. Open an issue or a PR.