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, no public issue queue.
Two ways to run it. Pick one.
Prerequisites — Bun 1.4+, Node 24.20+, pnpm 11, Docker with Compose v2.
git clone 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)
pnpm db:migrate # apply migrations
pnpm dev # API :3000, App :5173No 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 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 startsOpen http://localhost:5173, sign up with any email, you're in.
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 Railway/Fly via INTERNAL_AUTH_LAYERS for defense-in-depth. |
| Event-driven + audit | Transactional outbox + LISTEN/NOTIFY dispatcher → 82 typed events auto-emitted on every state change (35 public + 47 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–43 KB) + defaultPreload: "intent" — perceived latency near zero. |
| AI-pair ready | CLAUDE.md at the root + per-layer sub-CLAUDE.md. Your agent already knows the rules — Result/Option, no throw in domain, capability gates, vertical-slice modules. |
| Zero-warning pipeline | Husky + lint-staged + commitlint + pre-push CI (Biome, knip, jscpd, type-check). Conventional Commits enforced; dev→main merge triggers semantic-release. 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/NOTIFY+SELECT … FOR UPDATE SKIP LOCKEDdrain (multi-instance safe); built-in subscribers run inside the dispatch TX (audit + webhook fanout + notification fanout), useronEvent(...)handlers isolated post-commit - 82 typed events (35 public + 47 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 — root + per-layer
CLAUDE.md, 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–43 KB) - TanStack Query server-state · RHF + zod forms (loose/strict schema split) ·
next-themes+ View Transitions theme ·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) - Disaster-recovery runbook (PITR-first) · Railway reference deploy (config-as-code)
pnpm bootstrapclone-ability · Docker dev (native hot-reload or fully containerizedcompose watch) · Turborepo- Zero-warning pipeline (Biome · knip · jscpd · type-check · commitlint · semantic-release)
- 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 82-event rail, Railway deploy. What's left is deliberately short; the rest was cut rather than carried as a wishlist. Full detail in ROADMAP.md.
- 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.
- D.5 known debts — three, including a duplicate deletion email if the worker crashes mid-batch.
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 — sources in packages/drizzle/src/schema/ |
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, retry + idempotency, provider-side suppression |
| DNS prereq prod | SPF + DKIM (3 CNAMEs) + DMARC — see docs/INTEGRATIONS.md |
| EU region | supported via region: "eu-west-1" in adapter config |
Billing is wired via @better-auth/stripe (plugin v1.6+, SDK stripe@22+). 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@22.3.0 + @better-auth/stripe@1.6.23 |
| 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 apps/api/src/modules/billing/config.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 apps/api/src/modules/billing/config.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 | — |
app | built (apps/app/dev.Dockerfile) | 5173 | default | — |
seaweedfs | chrislusf/seaweedfs | 8333 | storage | seaweedfs_data |
seaweedfs-init | chrislusf/seaweedfs | — | storage | — |
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 three variables are required to boot in dev: DATABASE_URL, BETTER_AUTH_URL, BETTER_AUTH_SECRET. 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, or API_TOKEN_PEPPER 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 (Railway/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 — Railway, Fly.io, Render, Coolify, 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 (Railway) — a prod-validated config-as-code runbook for the 3 services (api + app + cron) + Postgres + R2: env baseline, per-service infra/railway/*.toml, the boot-trap gotchas hit in practice (NODE_ENV override, app start-command, cross-site cookies), cookie strategy by domain topology, and a provider-swap section (Fly / Render / Cloud Run — Dockerfiles are portable). See docs/DEPLOY-RAILWAY.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-PaaS recipes (Railway, Fly, Render, K8s, Cloud Run) in docs/HEALTH-PROBES.md.
Disaster recovery — PITR-first (delegated to your managed Postgres provider), with copy-paste recipes for a weekly portable pg_dump export and a monthly automated restore-test. RPO/RTO targets, restore runbook, lifecycle + versioning snippets 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.3+ (api, scripts, tests) · Node 24+ 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 11 · Turborepo · Biome 2 · Husky · semantic-release · 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: review pass, abuse signals, D.5 debts |
| Architecture rules | CLAUDE.md (root) and the per-layer sub-CLAUDE.md |
| 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 — PITR-first, restore runbook, weekly export + monthly restore-test recipes |
| Observability | docs/OBSERVABILITY.md — Sentry api+app, RGPD scrubbing, removability runbook, provider swap recipe |
| Deploy (Railway) | docs/DEPLOY-RAILWAY.md — config-as-code runbook, boot-trap gotchas, cookie topology, provider swap |
| 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 StudioMade by Axel Hamilcaro · © 2026-present — All rights reserved