clean-stack

July 3, 2026 · View on GitHub

clean-stack

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.


Quick start

Two ways to run it. Pick one.

Option A — Native (fastest hot reload)

PrerequisitesBun 1.3+, Node 24+, pnpm 10, 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 :5173

Option B — Fully containerized (Docker only)

No Bun, no Node, no pnpm on your host. Everything runs in containers (api + app + Postgres) with hot reload via compose develop.watch.

PrerequisitesDocker 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.).

OSOfficial guide
macOSDocker Desktop for Mac
WindowsDocker Desktop for Windows (WSL2)
DebianInstall Docker Compose on Debian
UbuntuInstall Docker Compose on Ubuntu
FedoraInstall Docker Compose on Fedora
RHEL / Rocky / AlmaInstall Docker Compose on RHEL
Arch / Manjaroextra/docker + extra/docker-compose (already v2, no plugin needed)
NixOSDocker — NixOS Wiki

v1 (docker-compose, hyphen) is EOL since June 2023 — always use docker 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 starts

Open http://localhost:5173, sign up with any email, you're in.


Why bother

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
AuthBetterAuth — 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-tenantorganizationId 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.
AuthorizationCapability-based SSOT (@packages/access-control). Same predicate at server middleware, route beforeLoad gate, and <Can requires={...}> UI — drift impossible by construction.
Legal / complianceDay-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 uploadsThree-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 + auditTransactional outbox + LISTEN/NOTIFY dispatcher → 38 typed events auto-emitted on every state change, append-only audit_log (SOC2 §CC7.2 / RGPD Art. 30), outbound webhooks (HMAC-signed, AEAD-encrypted secrets, decorrelated-jitter retry → dead-letter). 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 scopeReserved 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 safetyHono RPC end-to-end (hcWithType). No client to write, no schema to sync, refactor in API → red squiggle in App on save.
PerformanceBun-native Bun.serve() (~7 ms cold). Route-level code-splitting on the front (initial bundle ~588 KB, route chunks 1–43 KB) + defaultPreload: "intent" — perceived latency near zero.
AI-pair readyCLAUDE.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 pipelineHusky + lint-staged + commitlint + pre-push CI (Biome, knip, jscpd, type-check). Conventional Commits enforced; devmain merge triggers semantic-release. No --no-verify shortcut.

Features

Everything wired today, and the build order for what's next. Full inventory in docs/FEATURES.md; detailed plan with constraints in ROADMAP.md.

Shipped

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

  • organization plugin — 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, route beforeLoad gate, and <Can> UI

Security & hardeningthe 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, IETF RateLimit/RateLimit-Policy/Retry-After headers, 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} + Vite html.cspNonce), 'strict-dynamic', public /csp-report (IP-rate-limited + cross-origin CORP + document-uri origin filter) → security.csp.violation audit 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.rejected audit event
  • Hardened headers (HSTS, CSP frame-ancestors 'none', nosniff, Referrer-Policy, Permissions-Policy via Caddy) · credentialed CORS allowlist · prod boot fails hard on missing CORS_ORIGIN / signing keys

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 & transactionsthe hard distributed-systems part, already solved

  • Transactional outbox — domain events persisted in the same DB transaction as the state change (IUnitOfWork.run() + EventCollector AsyncLocalStorage) → 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 LOCKED drain (multi-instance safe); built-in subscribers run inside the dispatch TX (audit + webhook fanout), user onEvent(...) handlers isolated post-commit
  • 38 typed events, append-only audit_log (operational 90d / compliance 7y), HMAC-signed webhooks (AEAD-encrypted secrets, decorrelated-jitter retry → dead-letter, replay), X-Request-Id correlation
  • Zero plumbing post-clone — declare the event in @packages/eventsaddEvent() in the aggregate → run via uow.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 & conventionsthe 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, tsc points 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 throw in domain/application (Result), no null for absence (Option), value objects validated via zod, aggregates expose only get id(); ownership enforced at the port (ScopedRepository scopes organizationId/userId across 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 17 — TransactionService, org-scoped withOrg(table, orgId) helper, dedicated port 5433

Frontend & UI

  • App shell — sticky top-nav, org switcher, theme toggle, user menu, ⌘K command palette (capability-filtered)
  • TanStack Router code-based 2-file pattern (route + lazy page) — no codegen, no routeTree.gen.ts, near-zero TanStack Start migration
  • Route-level code-splitting + defaultPreload: "intent" (initial bundle ~588 KB, route chunks 1–43 KB)
  • TanStack Query server-state · RHF + zod forms (loose/strict schema split) · next-themes + View Transitions theme · sonner toasts
  • 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) · pino logging 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 bootstrap clone-ability · Docker dev (native hot-reload or fully containerized compose watch) · Turborepo
  • Zero-warning pipeline (Biome · knip · jscpd · type-check · commitlint · semantic-release)

Roadmap

Build order for a boilerplate — deploy-safety + legal non-negotiables first, then revenue, then finish/polish. Phase IDs link to their full spec in ROADMAP.md.

  • M1 — Deploy-safe & legal · ✅ rate-limit + strict CSP + CSRF shipped (Phase C.1 — see Security & hardening above; captcha + abuse-signals still pending) · compliance docs (sub-processors, accessibility, DPA, DORA) · cookie consent + GPC/DNT
  • M2 — Revenue · billing via @better-auth/stripe (per-org customer, portal, dunning) · feature & quota gating (config + middleware, no DDD)
  • M3 — Finish half-shipped UIs · audit-log front · webhooks front + webhook.test · recovery-codes UI · privacy dashboard
  • M4 — Operate · admin & impersonation (BetterAuth admin plugin) · API tokens / PATs (eval @better-auth/api-key) · OpenAPI docs (Scalar) · in-app notifications
  • M5 — Quality & compliance gates · Playwright e2e (full legal chain) + Lighthouse a11y CI · SOC2 Type II checklist · status page + SLO dashboards + OTel/Prometheus
  • M6 — Enterprise & reach · SSO SAML/OIDC + SCIM (BetterAuth sso) · i18n (TanStack locale routes + Lingui; @better-auth/i18n for auth errors) · Capacitor mobile · feature flags · marketing site

Infrastructure

What's in docker-compose.yaml, what's optional, and how dev maps to prod.

Database — Postgres 17

Imagepostgres:17-alpine
Host port5433 (deliberately not the default 5432 — avoids collision with a system Postgres)
In-networkpostgres:5432 (used by the api container)
Volumepostgres_data (persistent across compose down)
Healthcheckpg_isready every 5 s — api waits for healthy before starting
SchemaDrizzle ORM — sources in packages/drizzle/src/schema/
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:push runs with --force because Turbo pipes stdout in non-TTY, and drizzle-kit's interactive data-loss prompt would otherwise hang. Safe in dev. Prod uses db:migrate which doesn't prompt.

Storage — S3-compatible (opt-in)

Object storage for uploads. Off by default — the storage profile is opt-in. Turn it on when you need it.

Dev imagechrislusf/seaweedfs — Apache 2.0, ~96 MB (why not MinIO?)
Compose profilestorage (off by default)
Host portpinned to 8333 (ports: ["8333:8333"])
In-networkseaweedfs:8333 (set as S3_ENDPOINT for the api)
Bucketclean-stack — auto-created on startup by the seaweedfs-init sidecar
Authnone required in dev — accepts any access key / secret
Prod targetCloudflare R2 (zero egress fees) — same SDK, swap via S3_ENDPOINT + creds
docker compose --profile storage up -d         # start SeaweedFS + bucket init

The S3 client is provider-agnostic (region: "auto", forcePathStyle: true). Anything S3-compatible works: R2, AWS S3, Backblaze B2, Wasabi, Scaleway, Tigris.

Email — Resend (optional)

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.

SDKresend
Templatesdashboard-managed, retry + idempotency, provider-side suppression
DNS prereq prodSPF + DKIM (3 CNAMEs) + DMARC — see docs/INTEGRATIONS.md
EU regionsupported via region: "eu-west-1" in adapter config

Containers at a glance

ServiceImagePort (host)ProfilePersistent volume
postgrespostgres:17-alpine5433defaultpostgres_data
apibuilt (apps/api/dev.Dockerfile)3000default
appbuilt (apps/app/dev.Dockerfile)5173default
seaweedfschrislusf/seaweedfs8333storageseaweedfs_data
seaweedfs-initchrislusf/seaweedfsstorage

Environment variables

Three .env files, on purpose. Do not collapse them into one at the root — it's a safety rail, not an oversight.

FileHoldsWhy isolated
apps/api/.envDB password, auth secret, S3 keys, Resend key, RGPD/storage limitsBackend-only — never travels to the browser
apps/app/.envVITE_API_URL and other VITE_* varsVite 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/.envDATABASE_URLConsumed 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), or WEBHOOK_MASTER_KEY is missing — a silent fallback there is worse than a refused boot. 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.


Deployment

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.


Stack

LayerChoice
RuntimeBun 1.3+ (api, scripts, tests) · Node 24+ for tooling
APIHono 4 on native Bun.serve()
AppVite 8 · React 19 · TanStack Router/Query · Tailwind 4 · shadcn/ui
AuthBetterAuth + organization, twoFactor, passkey, magicLink, bearer
EmailResend (typed templates, idempotency, provider-side suppression)
StorageCloudflare R2 prod · SeaweedFS dev (S3-compatible, opt-in)
DBDrizzle ORM + Postgres 17
API ↔ AppHono RPC (hcWithType) — end-to-end types
DDD@packages/ddd-kit (Result, Option, Aggregate, ScopedRepository, …)
Toolingpnpm 10 · Turborepo · Biome 2 · Husky · semantic-release · knip · jscpd

Documentation

What ships todaydocs/FEATURES.md
What's nextROADMAP.md — Phase 0 ✅; build order: M1 security perimeter + consent → M2 billing → M3 finish audit/webhooks/recovery UI → M4 admin + PATs → M5 e2e/a11y gates → M6 SSO/i18n/mobile
Architecture rulesCLAUDE.md (root) and the per-layer sub-CLAUDE.md
Integrationsdocs/INTEGRATIONS.md — BetterAuth, Stripe, Resend, R2, email DNS
Eventsdocs/EVENTS.md — DX guide · docs/EVENT_PIPELINE.md — visual walkthrough
Health probesdocs/HEALTH-PROBES.md — endpoints, registry, graceful shutdown, per-PaaS recipes
Disaster recoverydocs/DISASTER-RECOVERY.md — PITR-first, restore runbook, weekly export + monthly restore-test recipes
Observabilitydocs/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
Historydocs/HISTORY.md — design decisions trail

Scripts

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 Studio

Made by Axel Hamilcaro · © 2026-present — All rights reserved