The SaaS boilerplate that says no. Auth, multi-tenant, email, storage already wired. You clone, you write business logic: everything else is settled.
Bun + Hono API · Vite + React 19 + TanStack app · Drizzle + Postgres · DDD-kit for the domain · BetterAuth + Resend + R2 for the SaaS layer.
Status: work in progress. This boilerplate is under active iteration. No support, no SLA. Issues and pull requests follow
CONTRIBUTING.md.
Two ways to run it. Pick one.
Prerequisites: Bun 1.4+, Node 26.13+, pnpm 12, Docker with Compose v2.
git clone --branch main https://github.com/axelhamil/clean-stack my-saas
cd my-saas
pnpm install
pnpm bootstrap # copies .env.example → .env in each workspace
docker compose up postgres -d # only Postgres on :5433 (api/app run natively below)
# uploads need the bucket: add --profile storage (SeaweedFS on :8333)
pnpm db:migrate # apply migrations
pnpm dev # API :3000, App :5173, Admin :5174No Bun, no Node, no pnpm on your host. Everything runs in containers (api + app + Postgres) with hot reload via compose develop.watch.
Prerequisites: Docker with Compose v2.22+ (the develop.watch minimum). Follow the official guide for your OS: do not use apt install docker.io (Canonical's package, not Docker Inc.).
| OS | Official guide |
|---|---|
| macOS | Docker Desktop for Mac |
| Windows | Docker Desktop for Windows (WSL2) |
| Debian | Install Docker Compose on Debian |
| Ubuntu | Install Docker Compose on Ubuntu |
| Fedora | Install Docker Compose on Fedora |
| RHEL / Rocky / Alma | Install Docker Compose on RHEL |
| Arch / Manjaro | extra/docker + extra/docker-compose (already v2, no plugin needed) |
| NixOS | Docker: NixOS Wiki |
v1 (
docker-compose, hyphen) is EOL since June 2023: always usedocker compose(space).
git clone --branch main https://github.com/axelhamil/clean-stack my-saas
cd my-saas
bash scripts/bootstrap.sh # copies .env.example → .env in each workspace
docker compose up --watch # api runs migrations on boot, then starts
# uploads need the bucket: docker compose --profile storage up --watchOpen http://localhost:5173. Sign-up requires email verification, and without RESEND_API_KEY mails are only logged (recipient and subject, not the link), so seed an account instead: pnpm db:seed, then sign in with it. Override the account with SEED_EMAIL, SEED_NAME and SEED_PASSWORD; defaults are in apps/api/scripts/seed-dev-user.ts.
Most SaaS templates ship a half-baked auth you'll rip out and zero opinion on what goes where. This one starts from the opposite premise.
| clean-stack | |
|---|---|
| Auth | BetterAuth: passkeys (WebAuthn), 2FA (TOTP + backup codes), magic-link, DB-backed sessions, cross-tab sync via BroadcastChannel. Bearer alongside cookies for Capacitor. Native Bun + Hono, no hacks. |
| Multi-tenant | organizationId FK on every business table from migration #1 + ScopedRepository enforces it at the port. Personal org auto-created on signup, role-based invitations, ownership transfer. Bolting tenancy on later is hell: the reverse is free. |
| Authorization | Capability-based SSOT (@packages/access-control). Same predicate at server middleware, route beforeLoad gate, and <Can requires={...}> UI: drift impossible by construction. |
| Legal / compliance | Day-one EU-legal across the GDPR surface: Art. 7 privacy/terms versioning (re-acceptance gate when a version bumps, user.policy.accepted evidence), Art. 16 rectification (profile + email-change + password), Art. 17 erasure (POST /me/delete: 2FA-required, 7-day soft-delete grace, sole-owner preflight, cancel-on-sign-in, cron wipe + ref anonymization), Art. 20 portability (POST /me/export: signed 7-day R2 URL, 1/24h throttle). Passwords follow NIST SP 800-63B (min 15, HIBP k-anonymity breach check, contextual ban-list, no forced complexity). Without this, fines up to 4% of revenue. |
| Direct uploads | Three-step presign → PUT direct to provider → server HeadObject confirm. Server is blind during transfer; owner-scoped keys (<userId>/<scope>/<uuid>-<filename>); R2 / S3 / B2 / Wasabi / Tigris: provider swap = one env var. |
| Internal endpoints | /internal/* (cron, queues) HMAC-SHA256-signed (X-Internal-Signature); signing key never on the wire. Stack private-network on Coolify or Fly via INTERNAL_AUTH_LAYERS for defense-in-depth. |
| Event-driven + audit | Transactional outbox + LISTEN/NOTIFY dispatcher → 100 typed events auto-emitted on every state change (35 public + 65 internal), append-only audit_log (SOC2 §CC7.2 / RGPD Art. 30), outbound webhooks (HMAC-signed, AEAD-encrypted secrets, decorrelated-jitter retry → dead-letter), operator UI at /settings/webhooks, public event catalog at /developers/events. Each audit row carries the request's X-Request-Id via an AsyncLocalStorage context: one key joins an audit entry to its logs and Sentry event. |
| DDD scope | Reserved for what your customers pay for. Not for billing, auth, gating, or quotas (config + middleware suffices). Lesson learned the hard way: ~70% less code than full-DDD on the SaaS plumbing. |
| Type safety | Hono RPC end-to-end (hcWithType). No client to write, no schema to sync, refactor in API → red squiggle in App on save. |
| Performance | Bun-native Bun.serve() (~7 ms cold). Route-level code-splitting on the front (initial bundle ~355 KB, route chunks 1 to 43 KB) + defaultPreload: "intent": perceived latency near zero. |
| AI-pair ready | CLAUDE.md at the root + one per app and per package. Your agent already knows the rules: Result/Option, no throw in domain, capability gates, vertical-slice modules. |
| Zero-warning pipeline | Husky + lint-staged + commitlint + a pre-push chain (Biome, boundaries, build, type-check, tests, jscpd, knip) that CI replays on every PR. Conventional Commits enforced; release-please keeps a release PR on develop, and merging it tags and moves main. No --no-verify shortcut. |
Everything wired today, and the short list of what's left. Prefer prose? docs/OVERVIEW.md is the guided tour. Full inventory in docs/FEATURES.md; detailed plan with constraints in ROADMAP.md.
Auth & identity
- Email + password (required verification + reset), magic-link, passkeys (WebAuthn), 2FA (TOTP + backup codes)
- Active-session list & revoke · bearer tokens alongside cookies · 5-min session cookie cache · cross-tab sync (
BroadcastChannel) - NIST SP 800-63B password baseline: min 15, HIBP k-anonymity breach check (fail-open), contextual ban-list, no forced complexity
Multi-tenant & authorization
organizationplugin: Personal org auto-heal, team orgs, email invitations, role-based members, ownership transfer- Capability-based authorization SSOT (
@packages/access-control): same predicate at server middleware, routebeforeLoadgate, and<Can>UI - Enterprise SSO + SCIM (
businesstier): per-org OIDC/SAML providers with DNS domain verification, JIT provisioning, a domain-wide enforce-SSO toggle closing all four sign-in paths, SCIM 2.0UsersCRUD (RFC 7644) for directory-driven provisioning and deprovisioning, local Keycloak profile for development
Security & hardening: the deploy-safe perimeter (Phase C.1)
- Unified rate-limit: one Hono middleware (
rate-limiter-flexible, BetterAuth built-in disabled) → global + 8 auth-burst policies, multi-window, IETFRateLimit/RateLimit-Policy/Retry-Afterheaders, fail-closed on auth (a store outage can't silently disable brute-force protection: OWASP A10:2025), trusted-proxy IP resolution (private/CIDR/exact, OWASP rightmost-non-trusted), memory → Postgres (dedicated pool) stores - Strict CSP: per-request nonce (Caddy native
{http.request.uuid}+ Vitehtml.cspNonce),'strict-dynamic', public/csp-report(IP-rate-limited + cross-origin CORP + document-uri origin filter) →security.csp.violationaudit event - CSRF: Origin-allowlist on unsafe methods (stateless, no token/cookie/endpoint: the Next.js Server Actions / SvelteKit model), reuses the CORS allowlist as SSOT, Bearer-skip for Capacitor,
security.csrf.rejectedaudit event - Hardened headers (HSTS, CSP
frame-ancestors 'none', nosniff, Referrer-Policy, Permissions-Policy via Caddy) · credentialed CORS allowlist · prod boot fails hard on missingCORS_ORIGIN/ signing keys - Abuse prevention (Phase C.1 s5a): disposable/throwaway email domains refused at sign-up (~90k-domain list + DNS MX-record check, fail-open,
DISPOSABLE_EMAIL_BLOCK_ENABLED) →security.signup.rejected; HIBP breach hit at sign-up/reset/change →security.password.breached
Legal / compliance (GDPR)
- Art. 7: privacy/terms versioning + re-acceptance gate (
@packages/policies,/legal/accept) - Art. 16: rectification (profile, email-change, password)
- Art. 17: erasure (2FA-gated, 7-day grace, sole-owner preflight, cron wipe + ref anonymization)
- Art. 20: portability (signed 7-day R2 export, 1/24h throttle)
Storage & email
- Direct uploads: three-step presign → PUT → confirm, owner-scoped keys (R2 / S3 / B2 / Wasabi / Tigris)
- Transactional email (Resend): typed templates, idempotency, provider-side suppression
Event-driven core & transactions: the hard distributed-systems part, already solved
- Transactional outbox: domain events persisted in the same DB transaction as the state change (
IUnitOfWork.run()+EventCollectorAsyncLocalStorage) → no event ever lost, none emitted for a rolled-back write (the dual-write problem, solved) - Post-commit dispatch: Postgres
LISTEN/NOTIFYwake + claim by lease (FOR UPDATE SKIP LOCKED, multi-instance safe, no lock held while handlers run); every consumer is a named, idempotent handler (audit, webhook fanout, notification fanout, useronEvent(...)) retried alone until it succeeds, dead events announced once and replayed - 100 typed events (35 public + 65 internal), append-only
audit_log(operational 90d / compliance 7y), HMAC-signed webhooks (AEAD-encrypted secrets, decorrelated-jitter retry → dead-letter, replay, SSRF guard, dual-secret rotation),X-Request-Idcorrelation - Zero plumbing post-clone: declare the event in
@packages/events→addEvent()in the aggregate → run viauow.run(); the audit row, webhook fanout, and in-process handlers (auto-discovered via inwire) come for free - Internal
/internal/*endpoints: HMAC-signed, optional private-network layer
Architecture & conventions: the part that keeps shipping fast sustainable
- Clean Architecture + DDD enforced by structure: layers import inward, modules never import each other (only via domain events / shared ports), each module removable by a documented contract (
trash+ line delete,tscpoints to the rest) - 8 cross-cutting rules, each tied to an architectural property with its why: every state change emits a typed event (audit + webhooks opt-out, not opt-in) · every I/O method spans + captures (no silent prod failure) · every event payload names its actor (RGPD forensic trail) · internal packages ship source not artifacts · ORM-first, raw SQL only where the ORM can't model it
- DDD scoped to the business domain only: never billing / auth / gating / quotas (config + middleware suffices); the discipline that cut ~70% of code vs full-DDD plumbing (lesson learned the hard way)
- Domain & repo rules: no
throwin domain/application (Result), nonullfor absence (Option), value objects validated via zod, aggregates expose onlyget id(); ownership enforced at the port (ScopedRepositoryscopesorganizationId/userIdacross HTTP, cron, queue, events: wrong owner leaks nothing) - Rules live next to the code: a root
CLAUDE.mdplus one per app and per package, recursively auto-loaded; conventions are executable guardrails for AI agents and humans, not tribal knowledge
Building blocks
- DDD-kit (
@packages/ddd-kit):Result/Option,Entity/Aggregate/ValueObject(zod-validated) /UUID/DomainEvent,BaseRepository/ScopedRepository/IUnitOfWork - Vertical-slice modular monolith:
modules/<context>/{domain,application,infrastructure}+ per-feature front slices - CQRS (commands via use-cases, queries direct to Drizzle) · inwire DI (type-inference container, no declared interfaces)
- Drizzle + Postgres 18:
TransactionService, org-scopedwithOrg(table, orgId)helper, dedicated port5433
Frontend & UI
- App shell: sticky top-nav, org switcher, theme toggle, user menu, ⌘K command palette (capability-filtered)
- TanStack Router file-based routing (
routes.tsvirtual config → generated, versionedrouteTree.gen.ts) with a 2-file feature pattern (route+ lazy component), near-zero TanStack Start migration - Route-level code-splitting +
defaultPreload: "intent"(initial bundle ~355 KB, route chunks 1 to 43 KB) - TanStack Query server-state · RHF + zod forms (loose/strict schema split) · View Transitions theme with a blocking init script ·
sonnertoasts - Typed i18n (
@packages/i18n):en/fr.tscatalogs bound tot()throughCustomTypeOptions, so an unknown key failstsc; locale from cookie thenuser.locale, never from the URL;en/frkey parity enforced by a test - shadcn-pure UI kit (
@packages/ui): typography exports + custom primitives (NavLink,TextLink,FormTextField,DestructiveActionDialog,ListRow), theme tokens,<Can>authz component
Infra, ops & DX
- Hono RPC end-to-end types (
hcWithType) ·pinologging with single error envelope · CQRS error contract - Health probes (
/livez/readyz/startupz) + graceful shutdown · Sentry error tracking (RGPD-scrubbed, NoOp without DSN) - Postgres PITR with pgBackRest to R2 and restore drills · Coolify reference deploy on a VPS (config-as-code, deploy by digest, hardened host)
pnpm bootstrapclone-ability · Docker dev (native hot-reload or fully containerizedcompose watch) · Turborepo- Zero-warning pipeline (Biome · knip · jscpd · type-check · commitlint · release-please)
- WCAG 2.1 AA gate on every PR:
@axe-core/playwrightover 11 pages in light and dark, plus landmark, keyboard, focus-trap and reduced-motion checks
The SaaS plumbing is shipped: auth, multi-tenant, RGPD, consent, billing, quota gating, security perimeter, admin + impersonation, API tokens, audit log, webhooks, in-app notifications, durable email, enterprise SSO + SCIM, typed i18n (en/fr), the 85-event rail, Coolify deploy. What's left is deliberately short; the rest was cut rather than carried as a wishlist. Full detail in ROADMAP.md.
- Dependency refresh Oct 2026 (Node 26 LTS, Bun 1.4.4, pnpm 12), in progress.
- Phase I, OpenUp resync: kit, architecture guardrails, telemetry hygiene, ambient unit of work and a leased outbox, in five lots.
- A manual review pass over the shipped surface: the last step before this is genuinely clone-ready.
- C.1 S5b/S6: impossible-travel, ASN deny-list, captcha hook. Deferred for calibration: these need real traffic to tune.
Cut and not returning: marketing site, status page + SLO dashboards, SOC2 checklist, OpenAPI docs, Capacitor shell, feature flags, GPC. Each was a per-clone decision, a third-party service to wire, or an ambition with nothing to exercise it. The specs live in git history.
What's in docker-compose.yaml, what's optional, and how dev maps to prod.
| Image | postgres:18-alpine |
| Host port | 5433 (deliberately not the default 5432: avoids collision with a system Postgres) |
| In-network | postgres:5432 (used by the api container) |
| Volume | postgres_data (persistent across compose down: not across a Postgres major bump, see below) |
| Healthcheck | pg_isready every 5 s: api waits for healthy before starting |
| Schema | Drizzle ORM: kit tables in packages/drizzle/src/schema/, business tables in modules/<name>/schemas/ |
Action required if you already had
postgres_datapopulated before this branch: the volume mount moved from/var/lib/postgresql/datato/var/lib/postgresql(Postgres 18+ images storePGDATAin a major-version-specific subdirectory: docker-library/postgres#1259). An existing volume won't crash ondocker compose up: it will silently reinitialize as an empty database, since the image finds nothing at the new mount point and just creates a fresh one. Rundocker volume rm clean-stack_postgres_dataonce before the firstupon this branch, then rebuild dev data withpnpm db:push && pnpm db:seed.
pnpm db:push # dev: push schema directly (drizzle-kit push --force, non-TTY safe)
pnpm db:generate # generate a SQL migration from a schema diff
pnpm db:migrate # apply migrations (prod-style, ship as artifacts)
pnpm db:studio # browse data in Drizzle Studio
db:pushruns with--forcebecause Turbo pipes stdout in non-TTY, and drizzle-kit's interactive data-loss prompt would otherwise hang. Safe in dev. Prod usesdb:migratewhich doesn't prompt.
Object storage for uploads. Off by default: the storage profile is opt-in. Turn it on when you need it.
| Dev image | chrislusf/seaweedfs: Apache 2.0, ~96 MB (why not MinIO?) |
| Compose profile | storage (off by default) |
| Host port | pinned to 8333 (ports: ["8333:8333"]) |
| In-network | seaweedfs:8333 (set as S3_ENDPOINT for the api) |
| Bucket | clean-stack: auto-created on startup by the seaweedfs-init sidecar |
| Auth | none required in dev: accepts any access key / secret |
| Prod target | Cloudflare R2 (zero egress fees): same SDK, swap via S3_ENDPOINT + creds |
docker compose --profile storage up -d # start SeaweedFS + bucket initThe S3 client is provider-agnostic (region: "auto", forcePathStyle: true). Anything S3-compatible works: R2, AWS S3, Backblaze B2, Wasabi, Scaleway, Tigris.
Resend is optional everywhere: without RESEND_API_KEY (or with unconfigured template IDs), email sends are logged at warn and the app boots normally; verification, magic-link, password-reset and org-invitation mails simply don't deliver. Wire it before relying on any email flow in prod.
| SDK | resend |
| Templates | dashboard-managed, a failed chunk is replayed with the same idempotency key, provider-side suppression |
| DNS prereq prod | SPF + DKIM (3 CNAMEs) + DMARC: see docs/INTEGRATIONS.md |
| Region | chosen per sending domain in Resend (e.g. eu-west-1); it only changes where mail is sent from, account data and logs stay in the US; no adapter option |
Billing is wired via @better-auth/stripe (plugin v1.7+, SDK stripe@23+, API version 2026-09-30.endive). Off until you add the two required env vars. Without them the billing routes return 402 for all subscription calls; the rest of the app is unaffected.
| Packages | stripe@23.0.1 + @better-auth/stripe@1.7.14 |
| Webhook endpoint | auto-mounted at /api/auth/stripe/webhook by the plugin |
| Subscription SSOT | plugin-managed subscription table (webhook-synced), not organization.metadata |
| Entitlements | typed ENTITLEMENTS[tier] in modules/billing/src/domain/entitlements.ts: edit + deploy to change gates |
| Tiers | free (3 members) · pro (20 members, audit_log + api) · business (unlimited members + SSO) |
| Unlimited | maxMembers: null (JSON-safe): not Infinity |
| Org model | unlimited team orgs per user; each is free or paid independently (seat-only gate) |
Required env vars (apps/api/.env):
STRIPE_SECRET_KEY=sk_test_...
STRIPE_WEBHOOK_SECRET=whsec_...
Dev webhook forwarding (forwards Stripe events to your local API):
stripe listen --forward-to localhost:3000/api/auth/stripe/webhookStripe product metadata contract: each paid Product (Pro, Business) in the Stripe dashboard must carry:
metadata.tier= the tier key (proorbusiness) matching a key inENTITLEMENTS. This is the join key between Stripe and the entitlements config.marketing_features(Stripe product field) = array of bullet strings shown in the/pricingUI and Stripe Checkout.
Entitlements (features, rank, maxMembers) live exclusively in code at modules/billing/src/domain/entitlements.ts. The Stripe dashboard never controls gates: it only carries the price and display copy. Changing a gate = a code change + deploy (reviewable, version-controlled).
| Service | Image | Port (host) | Profile | Persistent volume |
|---|---|---|---|---|
postgres |
postgres:18-alpine |
5433 |
default | postgres_data |
api |
built (apps/api/dev.Dockerfile) |
3000 |
default | n/a |
app |
built (apps/app/dev.Dockerfile) |
5173 |
default | n/a |
admin |
built (apps/admin/dev.Dockerfile) |
5174 |
default | n/a |
seaweedfs |
chrislusf/seaweedfs |
8333 |
storage |
seaweedfs_data |
seaweedfs-init |
chrislusf/seaweedfs |
n/a | storage |
n/a |
Three .env files, on purpose. Do not collapse them into one at the root: it's a safety rail, not an oversight.
| File | Holds | Why isolated |
|---|---|---|
apps/api/.env |
DB password, auth secret, S3 keys, Resend key, RGPD/storage limits | Backend-only, never travels to the browser |
apps/app/.env |
VITE_API_URL and other VITE_* vars |
Vite inlines VITE_* into the client bundle: these are publicly visible. Splitting prevents a backend secret from ending up in the JS bundle by mistake |
packages/drizzle/.env |
DATABASE_URL |
Consumed by drizzle-kit at migration time (separate process, separate cwd) |
pnpm bootstrap (or bash scripts/bootstrap.sh) copies each .env.example → .env if missing. Idempotent, never overwrites.
Only four variables are required to boot in dev: DATABASE_URL, BETTER_AUTH_URL, BETTER_AUTH_SECRET, APP_URL. Everything else is optional with sensible behavior when missing (storage off, email warns, etc.). In production the api additionally fails hard if CORS_ORIGIN, INTERNAL_SIGNING_KEY, INTERNAL_AUTH_LAYERS (incl. signature), WEBHOOK_MASTER_KEY, STRIPE_SECRET_KEY, STRIPE_WEBHOOK_SECRET (see docs/INTEGRATIONS.md section 4), API_TOKEN_PEPPER or SCIM_CREDENTIAL_HASH_SECRET is missing: a silent fallback there is worse than a refused boot. API_TOKEN_PEPPER (min 32 chars, openssl rand -hex 32) is the server secret that makes a database dump insufficient to forge tokens; API_TOKEN_PREFIX should be unique per clone (e.g. myapp_). Behind a proxy/PaaS, set TRUSTED_PROXIES=private (Coolify, Fly) or every request shares the LB IP as rate-limit key (collective lockout). See apps/api/.env.example for the full template.
The api ships an always-on event-driven rail (transactional outbox + Postgres LISTEN/NOTIFY dispatcher + webhook delivery worker, all in-process). Two deployment shapes follow from this:
✅ Compatible: Coolify, Fly.io, Render, dedicated VM, Kubernetes/EKS/GKE/AKS. Anywhere the api process stays alive between requests.
🟡 Compatible with min_instances ≥ 1: Google Cloud Run, AWS App Runner, Azure Container Apps. They scale-to-zero by default; with zero replicas the dispatcher dies and outbox_event rows pile up unhandled. Pin minimum 1 instance (Cloud Run: --min-instances=1) and you're fine.
❌ Incompatible without re-wiring: Vercel Functions, Netlify Functions, AWS Lambda, Cloudflare Workers, edge runtimes generally. Functions terminate after the response, killing LISTEN. To go serverless, swap the in-process dispatcher for a cron-triggered drain endpoint or an external queue (Inngest, QStash, SQS), see docs/EVENTS.md for the workaround. Edge runtimes also can't run the Postgres LISTEN client at all: keep the api on a regular runtime; if you need edge for specific endpoints, split them into a separate service.
Reference deploy (Coolify): a VPS run by Coolify v4 with 3 apps (api + app + admin) + a Postgres resource + R2. The desired state is TypeScript (infra/coolify/services.ts), reconciled by infra/coolify/apply.ts (dry run by default, never deletes, never touches a secret value); .github/workflows/deploy.yml builds the images after a green CI and deploys them by sha256 digest with health gates, rollback.yml redeploys previous digests. The runbook covers the host, Tailscale, Cloudflare, tokens, the boot-trap gotchas (NODE_ENV override, empty start command, stop grace above the shutdown deadline) and cookie topology. Dockerfiles stay portable. See docs/DEPLOY-COOLIFY.md.
Health probes: three endpoints (/livez, /readyz, /startupz) following K8s 2026 convention + IETF draft-inadarei format, with tri-state aggregation (pass/warn/fail) and SIGTERM-driven graceful shutdown. Per-platform recipes (Coolify, Fly, Render, K8s, Cloud Run) in docs/HEALTH-PROBES.md.
Disaster recovery: on the reference deploy, a Postgres 18 image with pgBackRest archives WAL continuously to an encrypted R2 repository (full weekly, differential daily, about 1 min RPO), with a local restore drill and a monthly drill in CI; on a managed Postgres, the provider's PITR. RPO/RTO targets, restore runbook and an optional weekly pg_dump export in docs/DISASTER-RECOVERY.md.
Observability: error tracking via Sentry on api + app, RGPD-clean payload scrubbing by default, pino integration for log breadcrumbs, global TanStack Query/mutation error capture + user-id tagging on the front, NoOp without SENTRY_DSN. OpenTelemetry tracing and Prometheus /metrics are deferred to Phase D.1 (managed alongside dashboards). Port usage, removability runbook, provider swap recipe (GlitchTip / Highlight) in docs/OBSERVABILITY.md.
| Layer | Choice |
|---|---|
| Runtime | Bun 1.4+ (api, scripts, tests) · Node 26+ for tooling |
| API | Hono 4 on native Bun.serve() |
| App | Vite 8 · React 19 · TanStack Router/Query · Tailwind 4 · shadcn/ui |
| Auth | BetterAuth + organization, twoFactor, passkey, magicLink, bearer |
| Resend (typed templates, idempotency, provider-side suppression) | |
| Storage | Cloudflare R2 prod · SeaweedFS dev (S3-compatible, opt-in) |
| DB | Drizzle ORM + Postgres 18 |
| API ↔ App | Hono RPC (hcWithType): end-to-end types |
| i18n | i18next + react-i18next: typed .ts catalogs (@packages/i18n), locale outside the URL |
| DDD | @packages/ddd-kit (Result, Option, Aggregate, ScopedRepository, …) |
| Tooling | pnpm 12 · Turborepo · Biome 2 · Husky · release-please · knip · jscpd |
| Guided tour | docs/OVERVIEW.md: what you get, in plain terms |
| What ships today | docs/FEATURES.md: file-level inventory |
| What's next | ROADMAP.md: plumbing shipped; left: dependency refresh, Phase I (OpenUp resync), review pass, C.1 abuse signals |
| Architecture rules | CLAUDE.md (root) and the per-app and per-package CLAUDE.md files |
| Back ↔ front surface map | docs/SURFACE.md: every live route, its front consumer or the reason it has none, enforced by a parity test |
| Integrations | docs/INTEGRATIONS.md: BetterAuth, Stripe, Resend, R2, email DNS |
| Events | docs/EVENTS.md: DX guide · docs/EVENT_PIPELINE.md: visual walkthrough |
| Health probes | docs/HEALTH-PROBES.md: endpoints, registry, graceful shutdown, per-PaaS recipes |
| Disaster recovery | docs/DISASTER-RECOVERY.md: pgBackRest PITR to R2, restore runbook, local and monthly drills, managed-Postgres alternative |
| Observability | docs/OBSERVABILITY.md: Sentry api+app, RGPD scrubbing, removability runbook, provider swap recipe |
| Deploy (Coolify) | docs/DEPLOY-COOLIFY.md: VPS and Coolify runbook, apply, deploy by digest, rollback, boot-trap gotchas, cookie topology |
| History | docs/HISTORY.md: design decisions trail |
pnpm bootstrap # copy .env.example → .env in each workspace (idempotent)
pnpm dev # Turbo TUI (all apps)
pnpm dev:docker # containerized dev (compose up --watch)
pnpm build # full build with parallel type-check
pnpm test # bun test (api) + vitest (rest)
pnpm ci:check # pre-push pipeline (Biome, type-check, knip, jscpd)
pnpm db:push # dev schema push
pnpm db:migrate # prod-style migration
pnpm db:studio # Drizzle StudioContributions are welcome through an issue first. The flow, the checks and what we decline are in CONTRIBUTING.md. Please follow the Code of Conduct, and report vulnerabilities privately as described in SECURITY.md.
Made by Axel Hamilcaro · © 2026-present: All rights reserved