Decision log
ADR log
Every “boring” choice in BoringStack, written up the same way: Choice / Why / What we gave up / When we’d reconsider. Short. Cross-linked. If a pick stops being load-bearing, the entry stops being honest.
4
decision categories
Honest
trade-off log
Revisable
when wrong
Data plane
Section titled “Data plane”Postgres
Choice. Postgres (vanilla, no extensions required) as the system of record.
Why. Battle-tested. Decades of operational tooling, every SaaS auditor recognises it, every hire has touched it. JSONB, partial indexes, and row-level security cover most real product needs.
What we gave up. Auto-scaling managed Postgres (Aurora, Neon, Planetscale). The single-VPS default takes manual restores and a manual hot-standby story until you’re ready to graduate. You also give up the per-branch ephemeral databases that some managed providers offer.
When we’d reconsider. When data integrity SLA, multi-AZ requirements, or branching DX matter more than ownership and cost.
Valkey (not Redis)
Choice. Valkey for cache + queue store.
Why. Identical wire protocol to Redis, drop-in compatible with ioredis and BullMQ, but BSD-licensed and maintained by the Linux Foundation with AWS, Google, and Oracle as primary sponsors. The Redis license change (RSAL/SSPL) in 2024 made the original incompatible with our default of “OSS core.”
What we gave up. Some Redis Enterprise features (Redis Modules, RediSearch), not in the BSD fork. We’ve never reached for them; the docs would call out the gap if we did.
When we’d reconsider. When a feature lives only in proprietary Redis and we can’t find an OSS path. Hasn’t happened.
Drizzle
Choice. Drizzle ORM for the API’s data layer.
Why. TypeScript-first. Queries look like SQL: no shadow database, no proprietary DSL. Migrations are plain SQL files you can read and edit. Generated types reach across the OpenAPI boundary into the UI.
What we gave up. Prisma’s broader ecosystem (Studio is decent but newer; tooling like Atlas integration is younger). No “magic” derived relations.
When we’d reconsider. If we needed multi-database (Mongo, SQL Server) within one ORM. Drizzle is Postgres-shaped first.
Runtime
Section titled “Runtime”Bun + Elysia
Choice. Bun runtime + Elysia HTTP framework for the API.
Why. Fast cold boot (sub-second), fast installs, native TypeScript execution. Elysia compiles routes to typed handlers and auto-emits OpenAPI. That one feature underpins the generated UI client.
What we gave up. Node’s mature ecosystem of native modules (some C++ addons still don’t compile under Bun). For our use cases (HTTP, Postgres, Valkey, email), we’ve never hit the gap.
When we’d reconsider. If a critical dep ships Node-only native modules and Bun-compat lands on the “soon” list permanently.
Astro for docs
Choice. Astro (with Starlight) for this docs site.
Why. Static-first with island components, MDX with full React when needed, Pagefind for fast client-side search, no JavaScript shipped where none is needed. Starlight handles the chrome (sidebar, header, theme toggle, mobile nav) so we focus on content.
What we gave up. A more general framework like Next.js or Nuxt. We don’t need dynamic backends in the docs surface.
When we’d reconsider. When docs need user-specific data (auth, customer-specific examples). Then add an API or move to a hybrid framework.
Bun everywhere (and not npm or pnpm)
Choice. Bun for all three app workspaces (API, UI, docs) and root orchestration. No npm, pnpm, or yarn as required tools.
Why. One installer/runtime story across the monorepo; Bun’s lockfile and CLI cover SPA, docs, and API workloads. Supply-chain pins live in each app’s bun.lock and osv-scanner.toml.
What we gave up. npm/yarn/pnpm-specific tooling (separate lockfile ecosystems, package-manager-only supply-chain knobs). Bun’s resolver and per-repo lockfiles are the trade.
When we’d reconsider. If a critical dependency breaks under Bun’s resolver and cannot be replaced or patched.
Edge & infrastructure
Section titled “Edge & infrastructure”Docker Compose (not Kubernetes)
Choice. Docker Compose for local dev and single-host production.
Why. Cluster machinery is dead weight for products under ~50k MAU. Compose v2 honours deploy.resources.limits, supports profiles and overlays, and one capable VPS handles a full SaaS spine. When you graduate, the same compose YAML can be re-targeted at a Swarm node or used as a reference shape for k8s manifests.
What we gave up. Auto-scaling, multi-host failover, the k8s ecosystem of operators. You don’t need them yet.
When we’d reconsider. When a single host’s cost no longer beats horizontal scaling on managed services, typically Scale-tier in the cost calculator.
Traefik (not Nginx or Caddy)
Choice. Traefik v3 as the reverse proxy in production.
Why. First-class Docker label-driven config means container restarts auto-update routes: no config file edits, no reload commands. ACME (Let’s Encrypt) HTTP-01 built in. Same-origin path routing (/ for SPA, /api/* for API) on one cert is a two-label change.
What we gave up. Nginx’s broader OSS deployment history. Caddy’s tighter config syntax.
When we’d reconsider. When config-file routing is more important than dynamic container discovery (e.g., a static multi-tenant fleet).
Cloudflare Tunnel (when paranoid) / Cloudflare proxy by default
Choice. Cloudflare’s free proxy in front of the VPS; optional Cloudflare Tunnel for hosts you want fully cloaked.
Why. Free DDoS absorption, free CDN, free DNS. The IP allow-list runbook + UFW closes 80/443 to anyone not coming through Cloudflare. Tunnel goes further: the VPS doesn’t even need a public IP.
What we gave up. Vendor neutrality at the edge. If Cloudflare ever becomes hostile or pricing changes meaningfully, the runbooks would need a rewrite around AWS CloudFront or Bunny.
When we’d reconsider. If Cloudflare’s free tier disappears, or a customer’s compliance posture forbids the data path.
OpenTofu (not Terraform)
Choice. OpenTofu for VPS bootstrap in infra/bootstrap.
Why. API-compatible with Terraform 1.5; community-governed under the Linux Foundation after Hashicorp’s BSL relicense. Same .tf files, same providers, no lock-in to a vendor that changed terms.
What we gave up. The newest Terraform-only providers and HCP-specific features. We don’t use them.
When we’d reconsider. If a critical provider goes Terraform-only with no OpenTofu equivalent.
Application architecture
Section titled “Application architecture”Architecture-as-lint
Choice. Custom ESLint plugins encode the route/service/types split, env access, queue shape, audit log discipline, and component anatomy. validate (typecheck + lint + tests) is the merge gate.
Why. Prose conventions get forgotten under deadlines or when an agent writes the diff. Machine-checked rules don’t. The cost of writing a custom plugin is a few hours; the cost of an architecture violation merging is a multi-month untangle.
What we gave up. Some flexibility for “just this once” exceptions; they fail CI. The plugins themselves are an additional surface to maintain (small one).
When we’d reconsider. When the rules feel like ceremony rather than load-bearing. That’s a signal the architecture changed shape and the lint needs updating, not removing.
OpenAPI between API and UI
Choice. The API auto-emits an OpenAPI document at /swagger/json. The UI runs bun run generate:api to regenerate a typed client from it. openapi-fetch makes wrong paths or wrong body shapes a typecheck error.
Why. Server-client drift is one of the highest-cost bugs in any SaaS. This makes drift a compile error, not a runtime 500.
What we gave up. A unified framework’s compile-time guarantees (tRPC, Next.js server actions). We picked an explicit boundary instead.
When we’d reconsider. If you’re building a Next.js app where server and client live in the same repo, tRPC or server actions are equally good. BoringStack splits them deliberately.
React (not Vue, Svelte, Solid)
Choice. React 19 + Vite for the UI.
Why. Largest pool of available hires and AI training data. Mature ecosystem for the parts SaaS apps actually need: forms (React Hook Form), data fetching (TanStack Query), accessibility primitives (Radix via shadcn/ui).
What we gave up. Svelte’s smaller bundles, Solid’s reactivity model, Vue’s template clarity. None of these matter at this scale of product.
When we’d reconsider. Never proactively; we’d only switch if React itself stopped being maintained.
Operations
Section titled “Operations”Mailpit in dev, pluggable in prod
Choice. Mailpit overlay for local development; pluggable provider abstraction (Cloudflare Email default, Resend, SendGrid, SMTP, noop) for production.
Why. Dev should never hit a real SMTP gateway. Production should never be locked into one vendor. The abstraction is one env var.
What we gave up. Provider-specific features like Resend’s React Email template integration; we use precompiled Handlebars instead.
When we’d reconsider. If a single provider’s lock-in features (template editors, suppression lists, advanced analytics) matter more than portability.
GlitchTip as the self-hosted Sentry
Choice. Sentry SDK on both sides, Sentry-compatible DSN. Hosted Sentry or self-hosted GlitchTip. Choose by DSN.
Why. Free observability if you self-host (one extra container, reuses base Postgres + Valkey). Wire-protocol parity with Sentry means the SDK doesn’t change.
What we gave up. Sentry’s full enterprise feature set (cron monitoring, advanced replay analytics). GlitchTip covers errors, breadcrumbs, and replays-on-error, the essentials.
When we’d reconsider. When you need a feature only Sentry has and the hosted bill is acceptable.
WUD hybrid image updates
Choice. What’s Up Docker runs hybrid in
prod: app images (api, ui) auto-pull + auto-recreate; base images
(Postgres, Valkey, Traefik) are notify-only.
Why. App images are rebuilt from our own CI and deployed frequently. Base images can carry migration risk and deserve operator review.
What we gave up. Full unattended updates for base services.
When we’d reconsider. When rollout orchestration is handled by a managed platform or multi-host scheduler with staged deploy controls.
Related
Section titled “Related”- Why BoringStack; the broader thesis these decisions support.
- Cost methodology; the dollar consequence of these picks.
- Stack at a glance; the dependency inventory.
- Lint as the contract; the architecture-as-lint pick, written out.