A private, multi-user Progressive Web App where families collect their stories — typed or spoken — and have them transcribed and gently rewritten into a shared, third-person family-memoir, placed on a timeline. Built to be self-hosted on a server you can SSH into.
| Layer | Choice |
|---|---|
| Framework | Next.js 16 (App Router) — web + installable PWA |
| UI | Mantine v9 (light-mode only) |
| Database | PostgreSQL + Drizzle ORM |
| Auth | better-auth (email/password + magic link) |
| Storage | S3-compatible (Hetzner Object Storage / Cloudflare R2 / MinIO locally) |
| Transcription | Groq Whisper (whisper-large-v3-turbo) |
| Story styling | OpenRouter (model configurable via STYLING_MODEL) |
| Jobs | pg-boss (Postgres-backed) + a separate worker process |
| Hosting | Hetzner VPS + Coolify |
- Start infrastructure (Postgres + MinIO):
docker compose up -d
- Configure env:
cp .env.example .env # then set OPENROUTER_API_KEY, GROQ_API_KEY, and a BETTER_AUTH_SECRET # (openssl rand -base64 32). The Postgres/MinIO defaults already match compose.
- Create the database schema:
npm run db:push
- Run the app + worker (two terminals):
npm run dev # web on http://localhost:3000 npm run worker:dev # transcription + styling worker
MinIO console: http://localhost:9001 (minioadmin / minioadmin).
npm run db:generate— generate SQL migrations from the schemanpm run db:migrate— apply migrations (use in production)npm run db:push— push schema directly (handy in dev)npm run db:studio— Drizzle Studionpm run lint,npm run build
Deploy two apps from this one repo/image:
- web — default role (
npm run start), exposed on the domain. - worker — same image with
ROLE=workerset, which switchesdocker-entrypoint.shtonpm run worker(Coolify has no start-command field for Dockerfile apps, so the role is an env var).
Both share the same environment variables (DATABASE_URL, OPENROUTER_API_KEY,
STYLING_MODEL, GROQ_API_KEY, S3 credentials, BETTER_AUTH_SECRET, BETTER_AUTH_URL).
The web container runs npm run db:migrate on startup to apply migrations. Coolify's reverse
proxy handles the domain + TLS. See INFRASTRUCTURE.md for the full ops/deploy detail.
Object storage CORS. Voice/photo uploads go straight from the browser to S3 via presigned
URLs, so the bucket must allow cross-origin PUT (and GET) from your app's domain. Configure
a CORS rule on the bucket allowing PUT, GET for your origin (locally, MinIO is set to allow
all origins via MINIO_API_CORS_ALLOW_ORIGIN in docker-compose.yml).