This is the full developer documentation for BoringStack # BoringStack > UI, API, and infra GitHub templates: React, Bun, Elysia, Postgres, Valkey, Docker Compose, OpenTofu, and ESLint architecture plugins. import LandingPage from "../../components/landing/LandingPage"; # Page not found > That page moved or never existed. Try one of these instead, or hit ⌘K to search the whole site. import DocCallout from "../../components/DocCallout"; Press ⌘K (or Ctrl+K) anywhere on the site to search every page. The index covers headings, body text, and code samples. Typing what you remember beats browsing. ## Popular starting points - **[Quickstart](/quickstart/)**: fork the templates, boot Compose, sign in. - **[Why BoringStack](/architecture/why-boringstack/)**: the thesis, the fit check, and what's in the box. - **[Stack at a glance](/architecture/stack/)**: runtime, libraries, and tools, one place. - **[Commands cheatsheet](/reference/commands/)**: every command you'll run on a normal day. - **[Lint as the contract](/architecture/lint-as-contract/)**: the rules that hold the architecture in place. - **[Deployment](/topics/deployment/)**: production runtime path from local boot to a live VPS. # ACL & feature resolution > CASL ability model, role rules, feature resolution order (override > plan > catalog default), and how account-scoping is enforced end-to-end. import DocCallout from "../../../components/DocCallout.tsx"; import DataMatrix from "../../../components/docs-kit/DataMatrix"; import FeatureGrid from "../../../components/docs-kit/FeatureGrid"; import FaqGroup from "../../../components/FaqGroup.tsx"; import FaqItem from "../../../components/FaqItem.tsx"; import PageIntro from "../../../components/docs-kit/PageIntro"; ACL is multi-tenant from day one. Solo-user products are the degenerate case: every signup creates a personal account, owner membership, and Free plan row in one transaction. The team UI appears only when memberships make it real. ## The three axes that feed every authorization check Stripe never knows about feature keys or roles. The app never queries Stripe at request time. The two systems touch only at the webhook boundary. ## The critical invariant `/me.rules` is a UI-rendering hint only. Every privileged API handler rebuilds the ability server-side from trusted DB state (`account_memberships` + resolved features), or wraps the DB call in `requireAbility(...)`. A modified client cannot escalate by sending crafted rules. ## Resolution order For every feature key at request time, resolved **per account**: 1. Active override in `account_feature_overrides` (not expired, not revoked). 2. `plan_features` row for the account's current `account_plans` row. 3. Catalog default from `src/lib/acl/acl.constants.ts` (`FEATURES[key].default`). First match wins. The CASL ability is built from the resolved feature set after that pass. The resolver (`resolveFeatures()` in `src/lib/acl/feature-resolution.ts`) is pure: feed it the plan rows and override rows, get back a typed `ResolvedFeatures` object. Tested in isolation. ## Roles Four roles ship by default, **per-membership** (a user is `owner` of Account A, `viewer` of Account B): Role rules carry CASL conditions (`{ accountId: membership.accountId }`) so resource ownership is encoded right at the rule, not duplicated in every handler. ## Feature gates `FEATURE_KEYS` is a code const tuple. Adding a feature is a TypeScript edit + a `bun run generate:acl-types` round-trip: ```ts // src/lib/acl/acl.constants.ts export const FEATURE_KEYS = [ "can_export", "can_invite_team", "max_seats", "max_widgets", ] as const; export const FEATURES = { can_export: { kind: "boolean", default: false }, max_seats: { kind: "limit", default: 1 }, // ... } as const; ``` Feature gates compose with role rules via CASL's `cannot(...)` rules: a missing feature forbids the action _regardless of role_. Owner is not special here. ## Account-scoping enforcement Every account-scoped Drizzle table carries a `// @account-scoped accountId` comment above its `pgTable` declaration. The companion ESLint rule (`drizzle-conventions/account-scoped-tables-require-where`, defense-in-depth, deferred from the main ACL pass) refuses to merge any `db.query..findX` that doesn't include the scope column in `WHERE`. The cross-account isolation matrix in `tests/api/widgets/widgets.routes.test.ts` is the proof: same user with two accounts, resource IDs unique across accounts, every method (`GET`, `PATCH`, `DELETE`) returns 404 when the resource belongs to the other account. ## Runtime helpers Per-request DB lookup with a 30s in-process TTL cache. Confirms the JWT-claimed `(user, account)` still maps to an active membership and that the parent account is not soft-deleted. Cache-bypassing variant for the highest-stakes calls: account deletion, ownership transfer, billing-portal creation, member removal, role changes. Always refetches. Returns `{ accountId }` so account-scoped queries pull their `WHERE` argument from one place. Throws `ApiErrors.forbidden()` with the action+subject in the message when the ability denies. Wraps every privileged DB call. Throws `ApiErrors.limitExceeded` (status 402) with `{ feature, current, limit }` so the UI can render an inline upgrade CTA. Wrap inside a transaction for race safety. ## /me response shape ```json { "user": { "id": "...", "email": "...", "firstName": "...", "lastName": "...", "emailVerified": true }, "account": { "id": "...", "name": "..." }, "role": "owner", "memberships": [ { "accountId": "...", "accountName": "...", "role": "owner" } ], "features": { "can_export": false, "can_invite_team": false, "max_seats": 1, "max_widgets": 5 } } ``` Single endpoint, every piece the UI needs to render the right buttons. The `features` block is the _resolved_ set; overrides have already been applied. Platform-admin status is a server-side flag (`users.is_platform_admin`) gating the separate `/admin/*` route mount and `requirePlatformAdmin` middleware. It never ships to the SPA. The product UI has no concept of "this user is also an admin," and `requireAbility` checks rebuild from DB state on every privileged call. If a fork wants admin tooling, build it as a separate app behind its own auth gate. ## Source - [`src/lib/acl/`](https://github.com/boringstack-xyz/boringstack/tree/main/apps/api/src/lib/acl): types, constants, feature-resolution, ability builder, `scopedTo`, `enforceLimit`. - [`src/api/accounts/`](https://github.com/boringstack-xyz/boringstack/tree/main/apps/api/src/api/accounts): `accountsService`, `invitationsService`, routes. - [`src/api/admin/`](https://github.com/boringstack-xyz/boringstack/tree/main/apps/api/src/api/admin): `adminBillingService` (grantFeature / revokeFeature / grantPlan). - [`src/middleware/require-active-membership.ts`](https://github.com/boringstack-xyz/boringstack/blob/main/apps/api/src/middleware/require-active-membership.ts): runtime membership recheck. - [`src/queues/account-maintenance/`](https://github.com/boringstack-xyz/boringstack/tree/main/apps/api/src/queues/account-maintenance): five daily idempotent sweep jobs (expire overrides, expire admin plans, downgrade canceled Stripe plans, hard-delete soft-deleted accounts, clean expired invitations). ## Related - [Multi-tenant model](/api/multi-tenant/): accounts, memberships, invitations, owner lifecycle. - [Authentication](/api/auth/): JWT carries `(user_id, account_id)`; account switch issues a fresh JWT. - [Billing](/api/billing/): Stripe webhook updates `account_plans`; plan status feeds effective-features mapping. # Audit log > Append-only, fire-and-forget log of security- and compliance-relevant events. Lives in its own Postgres schema. import PageIntro from "../../../components/docs-kit/PageIntro"; import SignalGrid from "../../../components/docs-kit/SignalGrid"; import FaqGroup from "../../../components/FaqGroup.tsx"; import FaqItem from "../../../components/FaqItem.tsx"; The audit log is the answer to "who did what, when?" Auth events, OAuth link/unlink, billing actions, and mutations on flagged resources all land in `audit.audit_log`. The shape is deliberately boring: append-only table, structured `metadata` blob, time-ordered. ## How a write happens ```mermaid flowchart LR caller["call site
after action succeeds"] -->|record| service["AuditLogService"] service -->|INSERT| db[(audit.audit_log)] service -.->|on failure| log["structured log
swallowed, never thrown"] ``` The `void` prefix at call sites is load-bearing: it tells the reader (and the linter) the caller is deliberately not awaiting. Audit can't be allowed to fail a real request. ## Design choices Independent grants, retention, and archival; app migrations on `public` cannot touch audit rows. A flaky audit table can never break a customer action. System events have no actor; "this account did X" history survives the user being scrubbed. Add new fields without a migration; cost is no per-field index. Magic strings drift; admin queries depend on a stable vocabulary. ## The action vocabulary Convention: `.`. Examples: - `auth.login_success`, `auth.session_created`, `auth.session_revoked`, `auth.password_reset_completed` - `billing.checkout_session_created`, `billing.portal_session_created` - `user.profile_updated` A `.` shape means admin queries can group cleanly: ```sql SELECT action, count(*) FROM audit.audit_log WHERE created_at > now() - interval '24 hours' GROUP BY 1 ORDER BY 2 DESC; ``` New event types add a constant before the first call site. Both code review and the lint plugin treat magic-string actions as a smell. ## Using it ```ts void auditLogService.record({ userId: actor.id, // null for system events action: AUDIT_ACTIONS.USER_PROFILE_UPDATED, resource: `user:${user.id}`, // optional metadata: { fieldsChanged: ["firstName"] }, // optional, PII-free }); ``` For the rare flow that must observe the write (e.g. a security event that has to be persisted before responding), drop `void` and check `success`. ## What `metadata` is for Structured context the action name doesn't capture. Keep it small and PII-free. - Good: `{ planId: "pro_monthly", previousPlanId: "free" }` - Bad: `{ email: "...", lastFourCardDigits: "..." }` The lint plugin flags common leak patterns (keys named `password`, `token`, raw `email`). ## What userId should be Actor's id. `null`. Acting admin's id, with `metadata.actingAs` set to the target. Never quietly attribute an admin's actions to the impersonated user. ## Adding a new event 1. Add a constant to `AUDIT_ACTIONS`. 2. After the action succeeds, `void auditLogService.record({...})`. 3. That's it. `/admin/audit-log` and the dashboard activity feed pick it up automatically because they query by action and recency. ## Retention The template ships no retention policy on purpose. Three reasonable shapes: - Cold storage: periodic `COPY ... TO` then `DELETE WHERE created_at < ...`. - Partitioning: `pg_partman` by month, drop old partitions. - None: for most B2B SaaS, an unbounded table is fine for years. Pick consciously. Don't let the table grow to "huge and slow" and then think about it. ## Operator queries Practical psql one-liners for "who did what" investigations. All assume you're connected to the app database (`docker compose exec postgres psql -U app -d app`). ```sql -- Last 50 events for a specific user, newest first. SELECT created_at, action, resource, metadata FROM audit.audit_log WHERE user_id = '' ORDER BY created_at DESC LIMIT 50; ``` ```sql -- Login + session activity in the last 24 hours, grouped by action. SELECT action, count(*) FROM audit.audit_log WHERE action LIKE 'auth.%' AND created_at > now() - interval '24 hours' GROUP BY 1 ORDER BY 2 DESC; ``` ```sql -- Every billing event ever recorded for one account. SELECT created_at, user_id, action, metadata FROM audit.audit_log WHERE action LIKE 'billing.%' AND resource LIKE 'account:%' ORDER BY created_at DESC; ``` ```sql -- Compliance export: every action this user took (for a GDPR data-subject request). SELECT created_at, action, resource, metadata FROM audit.audit_log WHERE user_id = '' ORDER BY created_at; ``` ```sql -- Rate-limiting candidates: actors with the most events in the last hour. SELECT user_id, count(*) AS events FROM audit.audit_log WHERE created_at > now() - interval '1 hour' GROUP BY 1 ORDER BY 2 DESC LIMIT 20; ``` ## Lint coverage [`@boring-stack-pkg/eslint-plugin-audit-log`](https://www.npmjs.com/package/@boring-stack-pkg/eslint-plugin-audit-log) flags: - Mutations on flagged tables that skip the audit write. - Magic-string `action` values that bypass `AUDIT_ACTIONS`. - Metadata payloads that look like a PII leak. ## Source [`src/lib/audit-log/`](https://github.com/boringstack-xyz/boringstack/tree/main/apps/api/src/lib/audit-log); service, types, constants. [`src/clients/postgres/schema/audit.schema.ts`](https://github.com/boringstack-xyz/boringstack/blob/main/apps/api/src/clients/postgres/schema/audit.schema.ts); the table. ## Related - [Authentication](/api/auth/); every auth event writes here. - [Lint as the contract](/architecture/lint-as-contract/); why the lint plugin matters. # Authentication > HttpOnly cookie auth with short-lived JWT access cookies, DB-backed refresh sessions, bcrypt-hashed passwords, and server-side OAuth. import { Aside } from "@astrojs/starlight/components"; import PageIntro from "../../../components/docs-kit/PageIntro"; import SignalGrid from "../../../components/docs-kit/SignalGrid"; import FaqGroup from "../../../components/FaqGroup.tsx"; import FaqItem from "../../../components/FaqItem.tsx"; Authentication follows one browser contract: HttpOnly cookies, server-side OAuth, verify-before-account signup, and DB-backed refresh sessions that can be rotated or revoked without exposing tokens to the UI. Two login flows share that contract: - Password: register / verify-email / login / forgot-password / reset-password. - OAuth: Google, GitHub, LinkedIn. Server-side; the SPA never holds a client secret. Both converge at the same point: `accountsService.provisionAfterVerification`, the single place that creates the personal account and owner membership. Without verification, there is no account, so abandoned signups leave no orphan tenant rows in the database. Once verified, a session ends the same way regardless of which flow produced it: - `auth_token`: a 15-minute stateless JWT in an `HttpOnly` cookie. The frontend never reads or stores it. - `refresh_token`: a 30-day opaque `HttpOnly` cookie. The API stores only its HMAC hash in `auth.sessions`, rotates it on every refresh, and can revoke it on logout or password reset. The model is hybrid: fast stateless access checks, stateful refresh sessions for revocation and safer long-lived login. ## Verify-before-account Password signup splits across two endpoints. `POST /auth/register` writes only the pending user row, the bcrypt hash, and a single-use verification token. No `app.accounts` row, no membership, no session cookies. The response is a `{ message }` envelope so the SPA can render "check your inbox at user@example.com." `POST /auth/verify-email` flips `users.email_verified_at`, atomically calls `provisionAfterVerification`, and _then_ issues the auth + refresh cookies. That's where the user gets their account. ```mermaid sequenceDiagram participant B as Browser participant API as API (Elysia) participant DB as Postgres participant Mail B->>API: POST /auth/register API->>DB: INSERT users (email_verified_at = NULL) API->>DB: INSERT user_auth_providers (password hash) API->>DB: INSERT email_verification_tokens API->>Mail: send verification link API-->>B: 200 message envelope (verification email sent) Note over B,API: NO cookies set. User cannot log in yet. B->>B: user clicks link in email B->>API: POST /auth/verify-email { token } API->>DB: UPDATE users SET email_verified_at = now() API->>DB: provisionAfterVerification then INSERT accounts + memberships API-->>B: 200 + auth_token + refresh_token (idempotent, double-click safe) ``` The OAuth flow lands at the same `provisionAfterVerification` call. Branches that converge there: - Brand-new OAuth signup with a provider-verified email: user created, provisioned, session issued. - Existing pending password-signup signing in with OAuth: user gets promoted to verified, the OAuth link is added, the account is provisioned. (This branch quietly fixed a pre-VBA bug where pending users could get OAuth-linked but never end up with an account.) - Existing already-verified user adding another OAuth provider: link added; `provisionAfterVerification` is idempotent (returns the existing account and membership). OAuth refuses to issue a session when the IdP says `emailVerified: false`. The transaction rolls back with no user row, no provider link, and no half-state. The caller has to verify through the password flow first. `POST /auth/login` with a still-pending user (correct password, `email_verified_at` is null) returns **403 `EMAIL_NOT_VERIFIED`**. The check fires after the password verify so an attacker who doesn't already know the password can't enumerate which addresses are pending versus unknown. The UI surfaces a resend-verification CTA pinned to the email the user typed. A daily `cleanStalePendingUsersJob` hard-deletes pending users older than 30 days (configurable). FK cascades drop the auth provider + verification token; `audit.audit_log` survives so the registration attempt remains traceable. ## How a request gets authenticated ```mermaid sequenceDiagram participant B as Browser participant API as API (Elysia) participant DB as Postgres B->>API: request with auth_token cookie API->>API: verify JWT (signature + exp) API->>DB: SELECT users WHERE id = DB-->>API: user row (or none, 401) API-->>B: response ``` `createAuthMiddleware` mounts this on protected route groups. Every handler in that group gets a typed `user` on its context. Token errors are categorized so the SPA can react cleanly (expired != malformed != missing). ## How refresh works ```mermaid sequenceDiagram participant B as Browser participant API as API (Elysia) participant DB as Postgres B->>API: POST /auth/refresh with refresh_token cookie API->>API: HMAC(refresh_token) API->>DB: UPDATE auth.sessions SET token_hash=, expires_at= WHERE token_hash= AND expires_at > now() alt matching live session DB-->>API: userId API->>DB: SELECT users WHERE id = userId API-->>B: set new auth_token + rotated refresh_token else missing / expired / replayed token DB-->>API: no row API-->>B: 401 end ``` Refresh-token rotation means a replayed old refresh token no longer matches a row. Logout deletes the current refresh session. Password reset deletes all refresh sessions for that user. ## Design choices XSS cannot read the access token because it is never exposed to JavaScript. CSRF protection without breaking same-origin dev. Normal API requests stay cheap: verify the cookie signature and expiry, then load the user row. Long-lived login lives in `auth.sessions`, keyed by a hash of an opaque token. The API can revoke one session or all sessions for a user. The SPA relies on browser-managed cookies and the generated OpenAPI client. Auth responses return `user`, not a bearer token. One user can hold a password and N OAuth links; no nullable password column. State must survive the cross-origin redirect; cookies on the IdP domain do not work. Replay attacks fail by construction. `/register` writes only the pending user row. The personal account + owner membership get created only at verify-email time (or inline at the OAuth callback when the IdP asserts the email is verified). Abandoned signups don't leave orphan tenant rows. Password order on `/login` is: dummy-verify on lookup miss, then bcrypt, then check `email_verified_at`. An attacker who doesn't know the password can't enumerate pending users. ## The OAuth round-trip ```mermaid sequenceDiagram participant SPA participant API participant Valkey participant IdP SPA->>API: GET /auth/oauth/:provider API->>Valkey: SETEX oauth:state: {codeVerifier} (10m TTL) API-->>SPA: 302 redirect to IdP authorize URL (state + PKCE challenge) SPA->>IdP: user authenticates IdP-->>API: 302 /auth/oauth/:provider/callback?code&state API->>Valkey: GETDEL oauth:state nonce Note over API,Valkey: null means replay/expired/forged, 401 API->>IdP: exchange code + codeVerifier IdP-->>API: profile API->>API: find-or-create user, create refresh session, sign access JWT API-->>SPA: set auth_token + refresh_token cookies API-->>SPA: 302 ${FRONTEND_URL}/oauth/success ``` The state record holds the PKCE code verifier. Reading it consumes it; a second callback with the same `state` finds nothing. The 10-minute TTL accommodates a slow IdP without leaving stale state lying around. ## Using it A protected route looks like: ```ts new Elysia().use(createAuthMiddleware()).get("/me", ({ user }) => ({ user })); // user: IUser, type-safe ``` Unauthenticated callers get a categorized 401 (`tokenExpired`, `invalidToken`, `missingCookie`). The UI client tries one guarded `/auth/refresh` when it sees a 401, then retries the original request. If refresh fails, `ProtectedRoute` redirects to login. ## Session storage The `auth.sessions` table stores: - `user_id` - `token_hash` (HMAC-SHA256 of the opaque refresh token, never the raw token) - `expires_at` - timestamps Email-verification and password-reset tokens follow the same rule: raw token only goes to the user, hash goes to Postgres. ## Adding an OAuth provider 1. Add it to `OAUTH_PROVIDERS` and the env-key map in [`oauth.manifest.ts`](https://github.com/boringstack-xyz/boringstack/blob/main/apps/api/src/lib/oauth/oauth.manifest.ts). 2. Drop a provider module in `src/lib/oauth/providers/` using Arctic's class for that IdP. 3. Add the client-id/secret pair to the env schema with a cross-field invariant ("required when provider enabled"). The lint plugins refuse to merge a provider that skips the state-consume or PKCE wire-up. ## Lint coverage - [`@boring-stack-pkg/eslint-plugin-jwt-cookies`](https://www.npmjs.com/package/@boring-stack-pkg/eslint-plugin-jwt-cookies); cookie attributes and JWT verify call sites. - [`@boring-stack-pkg/eslint-plugin-oauth-security`](https://www.npmjs.com/package/@boring-stack-pkg/eslint-plugin-oauth-security); state + PKCE invariants on the callback path. See [Lint as the contract](/architecture/lint-as-contract/) for why these matter. ## Source [`src/api/auth/`](https://github.com/boringstack-xyz/boringstack/tree/main/apps/api/src/api/auth) and [`src/lib/oauth/`](https://github.com/boringstack-xyz/boringstack/tree/main/apps/api/src/lib/oauth) on GitHub; the routes, the services, the OAuth state store, and the providers. ## Related - [Env validator](/api/env-validator/); enforces the OAuth-credentials-when-enabled invariant. - [Audit log](/api/audit-log/); every auth event writes an audit row. # Billing > Stripe subscriptions with configured plans, cookie-protected customer flows, raw-body webhooks, and Postgres-backed idempotency. import { Aside } from "@astrojs/starlight/components"; import DataMatrix from "../../../components/docs-kit/DataMatrix"; import FeatureGrid from "../../../components/docs-kit/FeatureGrid"; import PageIntro from "../../../components/docs-kit/PageIntro"; Billing is optional. When BILLING_ENABLED=false, the billing route group returns 404 and the API does not instantiate Stripe. When it is true, the env validator requires the Stripe secret key, webhook secret, and price IDs before the app listens. The template ships the subscription spine, not a pricing strategy. It wires Stripe Checkout, Stripe Customer Portal, plan persistence, webhooks, audit events, and redirect safety. Forks can rename plans or add tiers once the product shape is real. ## How checkout works ```mermaid sequenceDiagram participant UI as UI participant API as API participant DB as Postgres participant Stripe UI->>API: POST /api/v1/billing/stripe/checkout-session API->>API: verify auth_token cookie + resolve active account API->>API: allowlist successUrl + cancelUrl against FRONTEND_URL origin API->>DB: upsert Free / Pro plans from STRIPE_PRICE_ID_* API->>Stripe: create checkout session (account.stripe_customer_id) API->>DB: update account.stripe_customer_id when first needed API-->>UI: { url } UI->>Stripe: redirect browser to hosted checkout ``` The customer portal follows the same pattern: authenticated user, `returnUrl` allowlisted against `FRONTEND_URL`, Stripe returns a hosted URL. ## Design choices ## HTTP surface ## Webhook flow ```mermaid sequenceDiagram participant Stripe participant API participant DB as Postgres Stripe->>API: POST raw payload + Stripe-Signature API->>API: constructWebhookEvent(payload, signature) API->>DB: INSERT billing.stripe_webhook_events(event_id) ON CONFLICT DO NOTHING alt first delivery API->>DB: apply subscription side effect in same transaction API-->>Stripe: 200 received else duplicate delivery API-->>Stripe: 200 already processed end ``` Handled events: - `checkout.session.completed`: creates or updates `billing.account_plans` for the account and plan in session metadata. - `customer.subscription.updated`: maps the active Stripe price id back to a local plan and updates the account plan; also tracks `past_due`, `unpaid`, `paused`, `canceled`, `incomplete`, `trialing`, and `active` for the [feature resolver](/api/acl/#status-driven-features). - `customer.subscription.deleted`: marks the row revoked so the resolver falls back to the Free plan. - `invoice.paid` / `invoice.payment_failed`: status transitions for the active plan row. Unknown event types are logged at debug level and ignored. Late or out-of-order deliveries are tolerated because every status transition is keyed by `(account_id, stripe_event_received_at)`. ## Required env ## Source [`src/api/billing/`](https://github.com/boringstack-xyz/boringstack/tree/main/apps/api/src/api/billing) and [`src/clients/postgres/schema/billing.schema.ts`](https://github.com/boringstack-xyz/boringstack/blob/main/apps/api/src/clients/postgres/schema/billing.schema.ts) on GitHub. ## Operator queries Read-only psql snippets for "what's the subscription state right now?" questions. Run inside the app database (`docker compose exec postgres psql -U app -d app`). ```sql -- Active subscriptions grouped by plan. SELECT plan_id, status, count(*) FROM billing.account_plans WHERE revoked_at IS NULL GROUP BY 1, 2 ORDER BY 1, 2; ``` ```sql -- Accounts on the Pro plan, with when each started + last status transition. SELECT a.name, ap.status, ap.created_at, ap.updated_at FROM billing.account_plans ap JOIN app.accounts a ON a.id = ap.account_id WHERE ap.plan_id = 'pro' AND ap.revoked_at IS NULL ORDER BY ap.updated_at DESC; ``` ```sql -- Webhook deliveries in the last 24 hours by event type. SELECT event_type, count(*) FROM billing.stripe_webhook_events WHERE created_at > now() - interval '24 hours' GROUP BY 1 ORDER BY 2 DESC; ``` ```sql -- Failed-payment accounts that need attention. SELECT a.name, ap.plan_id, ap.status, ap.updated_at FROM billing.account_plans ap JOIN app.accounts a ON a.id = ap.account_id WHERE ap.status IN ('past_due', 'unpaid', 'incomplete') AND ap.revoked_at IS NULL ORDER BY ap.updated_at; ``` ```sql -- Confirm idempotency works: every webhook event_id should appear exactly once. SELECT event_id, count(*) FROM billing.stripe_webhook_events GROUP BY 1 HAVING count(*) > 1; ``` ## Related - [Authentication](/api/auth/); customer billing routes use cookie auth. - [ACL & feature resolution](/api/acl/); Stripe plan status drives the feature flags surfaced via `/me`. - [Multi-tenant model](/api/multi-tenant/); Stripe customer id and active plan live on `accounts`, not `users`. - [Audit log](/api/audit-log/); checkout and portal session creation write audit rows. - [Env validator](/api/env-validator/); Stripe keys and price IDs are enforced when billing is enabled. # Email > One pluggable interface, five providers (Cloudflare default, Resend, SendGrid, SMTP, noop). Templates precompiled to JSON at build time; dispatch is queue-aware. import CommandRun from "../../../components/docs-kit/CommandRun"; import PageIntro from "../../../components/docs-kit/PageIntro"; import SignalGrid from "../../../components/docs-kit/SignalGrid"; Email has a delivery spine without forcing one vendor. Providers, templates, and dispatch are independently swappable, while request handlers keep calling the same function. Three layers: 1. Provider: the wire to whoever actually delivers the mail. 2. Templates: Handlebars `.hbs` files precompiled to JSON at build time. 3. Dispatch: `sendTemplate(...)` chooses queue or inline based on env, so the call site doesn't care which. The default is [Cloudflare Email Service](/topics/cloudflare-email/) because it's the cheapest at scale. Resend, SendGrid, and a plain SMTP provider ship alongside; swapping is a one-env-var change. SMTP is what you use locally against [Mailpit](/topics/email-in-dev/). ## How a send flows ```mermaid flowchart LR caller["caller
sendTemplate(...)"] --> dispatch{QUEUES_ENABLED?} dispatch -- yes --> queue["enqueue
email-delivery"] queue --> worker["worker
processJob"] worker --> render["render template
(precompiled JSON)"] dispatch -- no --> render render --> provider["provider.send()
retryWithBackoff"] provider -- cloudflare/resend/sendgrid/smtp --> sent[(provider)] provider -- noop / missing key --> log["log only"] ``` Two retry layers stack when queues are on: the inner `retryWithBackoff` handles flickery HTTP responses, the outer BullMQ retry handles the case where the whole provider is down for minutes. ## Design choices ## The provider contract Every concrete provider implements one shape: ```ts interface IEmailService { send: (msg: { to; subject; html; text? }) => Promise<{ id; provider }>; readonly providerName: "cloudflare" | "resend" | "sendgrid" | "smtp" | "noop"; } ``` The selector reads `EMAIL_PROVIDER`. If the matching key is empty, it returns the noop provider; dev never crashes, prod boot fails earlier at the env validator. ## Templates Authors write `.hbs` files in `src/templates/email/templates/{auth,notifications}/`. The build script (`bun run build:templates`) compiles them to JSON. At runtime the template service reads the JSON and invokes the precompiled function. Net effect: zero parse cost per send, no template-injection surface. Shared layout partials live in `components/`. `baseTemplateVariables()` injects common context (product name, support URL, current year) so templates don't repeat it. ## Using it ```ts import { sendTemplate } from "../lib/email"; await sendTemplate({ to: user.email, subject: "Verify your email", templatePath: "auth/verify-email", variables: { token, confirmationUrl }, }); ``` Do not branch on queue vs inline in the handler; that decision lives in env config and `sendTemplate` / `sendTemplateNow`. ## Adding a provider 1. Create `src/lib/email/providers/.ts` implementing `IEmailService`. 2. Add it to the `EmailProviderName` union and the switch in `buildEmailService()`. 3. Add the API-key env var to the schema with the matching cross-field invariant. The HTTP call should be wrapped in `retryWithBackoff` so transient 5xx responses get retried inside the call, not just at the queue level. ## Adding a template 1. Drop a `.hbs` file in the right subfolder. 2. Run `bun run build:templates` (or the watcher in dev). 3. Reference it: `templatePath: "/"`. ## Lint coverage [`@boring-stack-pkg/eslint-plugin-structured-logging`](https://www.npmjs.com/package/@boring-stack-pkg/eslint-plugin-structured-logging) fails the build on unmasked email addresses in log calls or `console.log`-style leaks. ## Source [`src/lib/email/`](https://github.com/boringstack-xyz/boringstack/tree/main/apps/api/src/lib/email); providers, dispatch, template service. [`src/templates/email/`](https://github.com/boringstack-xyz/boringstack/tree/main/apps/api/src/templates/email); the `.hbs` sources and build pipeline. ## Related - [Cloudflare Email Service](/topics/cloudflare-email/); why it's the default. - [Setup runbook](/runbooks/cloudflare-email-setup/); domain + token wire-up. - [Queues](/api/queues/); the `email-delivery` queue on the worker side. # Env validator > TypeBox for shape, hand-written predicates for cross-field rules. Boot fails fast with every error listed; nothing else may read `process.env` directly. import CommandRun from "../../../components/docs-kit/CommandRun"; import FeatureGrid from "../../../components/docs-kit/FeatureGrid"; import PageIntro from "../../../components/docs-kit/PageIntro"; import SignalGrid from "../../../components/docs-kit/SignalGrid"; Env is deploy-time configuration, not runtime state. The validator runs once at boot, lists every problem it finds, freezes the result, and exposes a typed env object to the rest of the app. Two principles drive the design: - No silent fallbacks in production. A missing or malformed var fails the boot, with a readable error listing every problem, not just the first. - One place to read `process.env`. Direct `process.env.FOO` outside the validator is a lint error. ## How boot validates env ```mermaid flowchart LR raw["readRaw()
process.env + coercion"] --> shape{TypeBox
schema valid?} shape -- no --> err1["throw with every
shape error listed"] shape -- yes --> inv{cross-field
invariants pass?} inv -- no --> err2["throw with every
invariant error listed"] inv -- yes --> freeze["Object.freeze(env)"] freeze --> ready[("env exported")] ``` The two-pass split matters: running invariants on an already-shape-validated object means the error reads _"STRIPE_SECRET_KEY required when BILLING_ENABLED=true"_, not _"property STRIPE_SECRET_KEY should be string"_. ## Design choices ## Shape vs. invariant A shape rule expresses "this field must be a positive int between 1 and 65535." TypeBox does that: ```ts PORT: t.Integer({ minimum: 1, maximum: 65535, default: 3000 }), PUBLIC_API_URL: t.String({ minLength: 1 }), JWT_SECRET: t.String({ minLength: 32 }), EMAIL_PROVIDER: t.Union([t.Literal("cloudflare"), t.Literal("resend"), t.Literal("sendgrid"), t.Literal("smtp")]), ``` An invariant rule expresses "if A is true, B must be set." TypeBox can't say that cleanly. A predicate can: ```ts if (env.BILLING_ENABLED && env.STRIPE_SECRET_KEY === "") { errors.push("STRIPE_SECRET_KEY required when BILLING_ENABLED=true"); } ``` Predicates each return `string[]` and fan into one aggregated check, so every problem surfaces in one boot attempt. ## Current invariant set `NODE_ENV=test` skips most of these so integration tests don't need real provider credentials. ## What a bad boot looks like Several problems, one redeploy to fix all of them. ## Adding a new env var 1. Add the field to the TypeBox schema with the right type + default. 2. Add it to `readRaw()` with a parser helper (`toInt`, `toBool`, `toCsv`, `nonEmpty`, `toFloat`). 3. If it has a cross-field rule, write a `check*` predicate and add it to `checkInvariants()`. 4. Document it in `.env.example` (and in `compose/.env.example` if it flows through the prod profile). 5. Use `env.MY_VAR` everywhere. Don't touch `process.env` directly; the lint plugin will catch it. ## The lint contract [`@boring-stack-pkg/eslint-plugin-env-access`](https://www.npmjs.com/package/@boring-stack-pkg/eslint-plugin-env-access) is what makes the validator load-bearing: - `process.env.X` is only allowed inside `src/config/env/`. - The matching rule applies to `import.meta.env` on the UI side. Without this rule, somebody eventually writes `const x = process.env.FEATURE_FLAG ?? "default"` deep in a handler; undocumented, untyped, unvalidated. The lint catches it on first try. ## Source [`src/config/env/`](https://github.com/boringstack-xyz/boringstack/tree/main/apps/api/src/config/env); schema, validator, parsers. [`.env.example`](https://github.com/boringstack-xyz/boringstack/blob/main/apps/api/.env.example) is the per-var reference with comments. ## Related - [Authentication](/api/auth/), [Email](/api/email/), [Queues](/api/queues/); per-feature env requirements. - [Environment variables](/reference/env-vars/); cross-repo index. - [Lint as the contract](/architecture/lint-as-contract/). # Multi-tenant model > Accounts, memberships, invitations, ownership transfer, and soft-delete + hard-delete lifecycle. Multi-tenant from day one; solo users are the degenerate case. import DocFileTree from "../../../components/DocFileTree"; import FaqGroup from "../../../components/FaqGroup.tsx"; import FaqItem from "../../../components/FaqItem.tsx"; import PageIntro from "../../../components/docs-kit/PageIntro"; import SignalGrid from "../../../components/docs-kit/SignalGrid"; Accounts, memberships, invitations, and billing plans ship as product infrastructure. A solo user is just one user with one account, so the app can grow into teams without a second data model. ## The shape Every account-scoped row carries `account_id`. Routes derive that ID from the JWT (the `aid` claim, populated at login / refresh / register / OAuth callback), never from URL params. ## Signup Account creation is deliberately deferred until the email is verified, so abandoned signups never leave orphan tenant rows. The flow splits across two endpoints, both converging on a single function (`accountsService.provisionAfterVerification`) that creates the `accounts` row + owner membership atomically. ```mermaid sequenceDiagram participant B as Browser participant API as API (Elysia) participant DB as Postgres participant Mail B->>API: POST /auth/register API->>DB: INSERT users (email_verified_at = NULL) API->>DB: INSERT user_auth_providers (password hash) API->>DB: INSERT email_verification_tokens API->>Mail: send verification link API-->>B: 200 message envelope (no cookies, account, or membership yet) B->>B: user clicks link in email B->>API: POST /auth/verify-email { token } API->>DB: UPDATE users SET email_verified_at = now() API->>DB: provisionAfterVerification then INSERT accounts + memberships API-->>B: 200 + auth_token JWT (carries user_id + account_id) ``` `provisionAfterVerification` is idempotent: a doubled-up verify click or an OAuth-then-password collision can call it twice, and only one active owner membership per user exists. The `buildPersonalAccountName({ firstName, lastName, email })` util produces the account name; if both names are empty it falls back to the email. The OAuth callback uses the same provision function inline at the end of the callback transaction, so an OAuth user with a provider-verified email lands fully provisioned in one round-trip. OAuth refuses to issue a session when the IdP says the email is unverified; the transaction rolls back, and the caller has to verify through the password flow first. See [Authentication](/api/auth/#verify-before-account) for the full state machine. ## Memberships `auth.account_memberships` is the (user, account, role) join. Two partial unique indexes lock down the invariants the application logic depends on: - `uniq_account_memberships_active_user`: `(account_id, user_id) WHERE revoked_at IS NULL`. At most one active membership per `(user, account)`. - `uniq_account_memberships_active_owner`: `(account_id) WHERE role = 'owner' AND revoked_at IS NULL`. At most one active owner per account. Revoked memberships keep their row (soft-delete via `revoked_at`) so the audit trail survives. ## Invitations ```ts POST /api/v1/accounts/:id/invitations // owner | admin POST /api/v1/invitations/accept // any authenticated user holding the raw token POST /api/v1/accounts/:id/invitations/:iid/resend DELETE /api/v1/accounts/:id/invitations/:iid ``` The route response carries the raw token exactly once (so the caller can hand it to whatever email pipeline they want). The DB only stores `sha256(token + pepper)`. Rotating on resend invalidates any leaked old link. The caller re-emails the new raw token. Old emails stop working immediately. Subsequent accept attempts fail with `invitation_revoked`. The daily `cleanExpiredInvitationsJob` background sweep also soft-revokes unaccepted invitations past their TTL. At invitation creation AND at acceptance time. An admin could revoke a seat between create + accept; the second check catches that. ## Owner lifecycle `POST /api/v1/accounts/:id/transfer-ownership` (owner-only, cache-bypassing `resolveFreshMembership`). Atomically demotes the current owner to `admin`, promotes the target to `owner`. The partial unique index is never violated mid-transaction. `accountsService.transferOwnership` demotes the outgoing owner FIRST so the index never sees two owners simultaneously. An owner cannot leave their account; they must transfer first or delete the account. An owner cannot be removed by an admin. `DELETE /api/v1/accounts/:id` (owner-only) sets `accounts.deleted_at = now()`. The `hardDeleteSoftDeletedAccountsJob` background sweep hard-deletes rows past the grace window, cascading to memberships, invitations, feature overrides, account_plans, and every `@account-scoped` application table. `audit.audit_log` survives by design. GDPR redaction (hash the user id, keep the row) is a separate path. ## Domain claiming (optional, B2B mode) Off by default. Flip `ACCOUNT_DOMAIN_CLAIMING=true` and the first verified signup with a non-public email domain claims that domain on its personal account. Subsequent verified signups from the same domain fail with `DOMAIN_CLAIMED` (409) and the existing account's name in the error message + accountId in details. The blocked user can still be invited via the standard `account_invitations` flow. The flag is the right shape for a B2B product where one email domain maps to one workspace (think Linear, Vercel, or Dreamdata's signup). For consumer products, leave it off; the public-email allowlist would be moot anyway. Clicking the verification link demonstrates control of an inbox at the domain. That's enough for a starter template; harder evidence (DNS TXT records, SAML/SCIM) is a follow-up an operator can layer on without changing the claim mechanism. `src/lib/email-domain/public-domains.ts` ships a 51-entry allowlist (gmail.com, outlook.com, proton.me, …). Signups from those addresses always get a fresh personal account because no single company owns those domains. `uniq_accounts_claimed_domain_active` is the DB-level safety net. Soft-deleted accounts (`deleted_at IS NOT NULL`) release their claim, so a successor signup after a deletion gets the domain back. Two live accounts can never share a claim. `app.account_join_requests` is in the schema with the partial unique index `uniq_account_join_requests_pending`. The request / approve / deny endpoints are intentionally not shipped. Operators flipping the flag on can wire that surface to whatever notification model suits their product (Slack ping, in-app inbox, email approval). ```mermaid sequenceDiagram participant B as Browser participant API as API participant DB as Postgres B->>API: POST /auth/verify-email { token } (founder@acme.corp) API->>DB: provisionAfterVerification then claims acme.corp API-->>B: 200 + cookies, account exists Note over API,DB: Some time later B->>API: POST /auth/verify-email { token } (intruder@acme.corp) API->>DB: provisionAfterVerification then existing claim found API-->>B: 409 DOMAIN_CLAIMED { message: "…Acme Corp…", details: { accountId, domain } } ``` The decision lives entirely inside `provisionAfterVerification`: read the flag, extract the domain via `extractDomain(email)`, bail if `isPublicEmailDomain(domain)`, otherwise look up an active claim and either reuse / claim / throw. ## Account switch `POST /api/v1/accounts/switch` with a target `accountId` re-issues the JWT with the new active account in the `aid` claim. The client re-fetches `/me`. Old JWTs continue to work against the old account until their 15-minute access TTL expires; the refresh-time membership recheck blocks renewal if the user no longer has an active membership on that account. ## Cross-account isolation tests `tests/api/widgets/widgets.routes.test.ts` is the proof point. Same user holds memberships in two accounts; resource IDs are unique across accounts; every method on Account B's widget returns 404 (not 403, not 200) when the request comes from Account A's JWT. The matrix is the canonical pattern to copy for any new account-scoped resource. ## Source - [`src/api/accounts/`](https://github.com/boringstack-xyz/boringstack/tree/main/apps/api/src/api/accounts) - [`src/clients/postgres/schema/app.schema.ts`](https://github.com/boringstack-xyz/boringstack/blob/main/apps/api/src/clients/postgres/schema/app.schema.ts): `accounts`, `account_invitations`, `account_feature_overrides`, `widgets` (sample account-scoped resource). - [`src/clients/postgres/schema/memberships.schema.ts`](https://github.com/boringstack-xyz/boringstack/blob/main/apps/api/src/clients/postgres/schema/memberships.schema.ts): `account_memberships` with both partial unique indexes. ## Operator queries Read-only psql snippets for tenant-shape questions. Run inside the app database (`docker compose exec postgres psql -U app -d app`). ```sql -- All active memberships for one account. SELECT m.role, u.email, m.created_at FROM auth.account_memberships m JOIN auth.users u ON u.id = m.user_id WHERE m.account_id = '' AND m.revoked_at IS NULL ORDER BY m.role, m.created_at; ``` ```sql -- Members per role per account (top 20 accounts by size). SELECT a.id AS account_id, a.name, m.role, count(*) AS members FROM auth.account_memberships m JOIN app.accounts a ON a.id = m.account_id WHERE m.revoked_at IS NULL GROUP BY 1, 2, 3 ORDER BY members DESC LIMIT 20; ``` ```sql -- Accounts with NO active owner. Should always return zero rows. -- If it doesn't, something bypassed the owner-transfer flow. SELECT a.id, a.name, a.created_at FROM app.accounts a WHERE NOT EXISTS ( SELECT 1 FROM auth.account_memberships m WHERE m.account_id = a.id AND m.role = 'owner' AND m.revoked_at IS NULL ); ``` ```sql -- Users who belong to more than one active account (team members or operators). SELECT u.email, count(*) AS accounts FROM auth.users u JOIN auth.account_memberships m ON m.user_id = u.id AND m.revoked_at IS NULL GROUP BY 1 HAVING count(*) > 1 ORDER BY 2 DESC; ``` ```sql -- Pending invitations older than 14 days, by account. SELECT i.account_id, i.email, i.created_at FROM app.account_invitations i WHERE i.accepted_at IS NULL AND i.revoked_at IS NULL AND i.created_at < now() - interval '14 days' ORDER BY i.created_at; ``` ## Related - [ACL & feature resolution](/api/acl/): how role rules, feature gates, and the resolved feature set compose into a CASL ability. - [Authentication](/api/auth/): JWT issuance carries `aid` (active account); session refresh re-validates membership. # Notifications > Async per-user notification dispatch over BullMQ. In-app, email, and SSE channels. Event-typed, deduplicated, preference-aware, framework only. Fork users author their own events. import PageIntro from "../../../components/docs-kit/PageIntro"; import CommandRun from "../../../components/docs-kit/CommandRun"; import SignalGrid from "../../../components/docs-kit/SignalGrid"; import FaqGroup from "../../../components/FaqGroup.tsx"; import FaqItem from "../../../components/FaqItem.tsx"; A `notifications.send(event, args)` call validates the payload, enqueues a BullMQ job, and returns. A worker resolves the event definition, runs dedup + self-action guards, checks per-user preferences, persists the notification row + per-channel delivery rows, then fans out to channel handlers. ## How a notification flows ```mermaid sequenceDiagram participant Caller as Service / route participant Dispatcher as notifications.send participant Queue as notification-dispatch queue participant Worker as Dispatch worker participant DB as Postgres participant Email as email-delivery queue participant SSE as Valkey pub/sub Caller->>Dispatcher: send(event, { recipientUserId, payload }) Dispatcher->>Dispatcher: Value.Check(schema, payload) Dispatcher->>Queue: enqueue (QUEUES_ENABLED) or run inline Queue-->>Worker: next job Worker->>Worker: lookup event, self-action guard, dedup Worker->>Worker: resolve user preferences Worker->>DB: INSERT notification + per-channel delivery rows par in-app Worker->>DB: UPDATE delivery SET status=sent (row IS the in-app) and email Worker->>Email: enqueue email job (settles delivery on completion) and sse Worker->>SSE: PUBLISH notifications:user: end ``` ## Design choices Uses the audit-log pattern already in the codebase: `void notifications.send(...)`. Delivery never blocks the originating request. `runNotificationDispatch` runs identically in the worker and the inline fallback, so dev and tests don't need a worker process. In-app and email ship by default. SSE is opt-in with `NOTIFICATIONS_SSE_ENABLED=true`. New channels (web-push, SMS, custom) implement `INotificationChannel` and register at boot via `channelRegistry.register(channel)`. Author handlers receive a `payload` typed by the event's schema. The dispatcher validates with `Value.Check` before enqueuing; the worker re-validates before any handler runs. Events declare `dedup: { key, windowSeconds }`. A unique index on `notification_dedup.dedup_key` short-circuits duplicate dispatches inside the window. Cleanup runs hourly via a repeatable maintenance job. Disabled channels still record a `notification_delivery` row with `status: suppressed`, easier to debug "why didn't I get an email?" with a row to point at. Forks define their own. Removing example product code on adoption is friction; the scaffolder makes adding the first one a one-liner. ## Authoring an event Generates `src/api/notifications/events/comment-replied.event.ts` and appends to the registry barrel. Edit the schema + render functions to match the domain: ```ts import { t } from "elysia"; import { defineNotificationEvent } from "../../../lib/notifications"; export const commentRepliedEvent = defineNotificationEvent({ type: "comment.replied", schema: t.Object({ actorId: t.String({ format: "uuid" }), actorName: t.String(), parentCommentId: t.String({ format: "uuid" }), excerpt: t.String({ maxLength: 200 }), }), defaultChannels: ["in-app", "email"], dedup: { key: ({ recipientUserId, payload }) => `comment.replied:${recipientUserId}:${payload.parentCommentId}`, windowSeconds: 3_600, }, selfActionGuard: ({ recipientUserId, payload }) => recipientUserId === payload.actorId, render: { inApp: ({ payload }) => ({ title: `${payload.actorName} replied to your comment`, body: payload.excerpt, ctaUrl: `/comments/${payload.parentCommentId}`, ctaLabel: "View reply", }), email: { subject: ({ payload }) => `${payload.actorName} replied to your comment`, templatePath: "notifications/comment-replied", variables: ({ payload }) => ({ actor: payload.actorName, excerpt: payload.excerpt, }), }, }, }); ``` ## Sending one ```ts import { notifications } from "@/lib/notifications"; import { commentRepliedEvent } from "@/api/notifications/events/comment-replied.event"; void notifications.send(commentRepliedEvent, { recipientUserId: parentComment.userId, payload: { actorId: currentUser.id, actorName: currentUser.displayName, parentCommentId: parentComment.id, excerpt: reply.body.slice(0, 200), }, }); ``` The `payload` is TypeScript-checked at the call site against `commentRepliedEvent.schema`. A bad shape fails to compile. ## HTTP surface | Endpoint | Purpose | | --- | --- | | `GET /api/v1/notifications` | List the current user's notifications with stable cursor pagination and an optional `status` filter. | | `PATCH /api/v1/notifications/:id` | Mark read / archived. | | `POST /api/v1/notifications/mark-all-read` | Bulk mark every unread row read. | | `GET /api/v1/notifications/preferences` | List per-event-type, per-channel toggles. | | `PUT /api/v1/notifications/preferences` | Bulk upsert preferences (UI submits the full settings page at once). | | `GET /api/v1/notifications/stream` | Server-Sent Events stream for the current user. | ## Realtime: SSE + Valkey pub/sub SSE is disabled unless `NOTIFICATIONS_SSE_ENABLED=true`. When enabled, the SSE endpoint subscribes to `notifications:user:` on Valkey. When the SSE channel implementation publishes after persistence, the message is forwarded to every connected client of that user, including clients on different API instances. ```mermaid flowchart LR apiA["API instance A
worker publishes"] -->|PUBLISH| valkey[(Valkey)] valkey -->|message| apiB["API instance B
SSE client holds this connection"] apiB -->|data:| browser["Browser EventSource"] ``` The SSE handler hooks the request's `AbortSignal`: when the tab closes, the generator's `finally` block disconnects the Valkey subscriber. No connection leak. If SSE is disabled, the endpoint returns 404 so the feature cannot accidentally look half-on. Messages use a stable JSON envelope: ```json { "type": "notification.created", "notification": { "id": "...", "eventType": "comment.replied", "title": "Someone replied", "body": "...", "ctaUrl": "/comments/...", "ctaLabel": "View reply", "status": "unread", "readAt": null, "createdAt": "2026-05-15T12:00:00.000Z" } } ``` ## Web Push channel (v1.1) Browser push notifications via the W3C Push API + VAPID. Plugged into the existing dispatcher without touching the fan-out logic. The channel registers itself conditionally when the VAPID env triplet is configured. Generate a fresh VAPID keypair: `bun run vapid:generate`. Paste the three lines it prints into `.env.local` (server) and put the public key into the UI's `VITE_VAPID_PUBLIC_KEY`. All three server vars must be set together: `validate.ts` rejects partial configuration. `POST /api/v1/notifications/push/subscribe` upserts a row keyed on `(userId, endpoint)`. `DELETE /api/v1/notifications/push/subscribe` removes by endpoint. `GET /api/v1/notifications/push/subscriptions` lists the user's own devices for a "Devices" panel. All three require the standard auth cookie. One Drizzle table, `notifications.push_subscription`: `(userId, endpoint, p256dhKey, authKey, userAgent?, expiresAt?, createdAt, lastUsedAt)`. Unique on `(userId, endpoint)` so the same browser re-subscribing rotates keys instead of creating duplicates. The channel resolves all live subscriptions for the recipient at delivery time and enqueues one `web-push-delivery` job. The worker fans out per-subscription POSTs in parallel and settles the `notification_delivery` row: `sent` if any subscription accepted the payload, `failed` if every attempt errored, `suppressed` if the user had no live subscriptions. A 410 Gone (or 404) from the push service means the browser has invalidated the subscription. The worker deletes the row eagerly and logs `notifications.web_push.subscription_expired`, so no orphan rows accumulate. `setup-notifications.ts` only calls `channelRegistry.register(webPushChannel)` when all three `WEB_PUSH_VAPID_*` env vars are set. A fork that doesn't ship Web Push never sees the channel in the registry. ## Out of scope (v1) Email verification, password reset, etc. stay on the direct `sendTemplate(...)` path. Transactional ≠ subscription; preferences shouldn't be able to silence them. A "you have 5 new" rollup is future work; the persistence model has the data ready. Future. Dedup catches obvious duplicates; a separate rate-limit middleware would catch "100 different events in a minute." ## Source - `src/api/notifications/`: HTTP routes, schemas, service, SSE handler, scaffolded events, push subscription endpoints (`notifications.push.*`). - `src/lib/notifications/`: channels (in-app, email, sse, web-push), dispatch pipeline, event + channel registries, preferences, dedup, Valkey pub/sub. - `src/queues/notification-dispatch/`: BullMQ queue + worker. - `src/queues/notification-maintenance/`: repeatable dedup cleanup job. - `src/queues/web-push-delivery/`: BullMQ queue + worker for per-subscription HTTP fan-out to push services. - `src/clients/postgres/schema/notifications.schema.ts`: five tables in the `notifications` Postgres schema (notification, notification_dedup, notification_delivery, notification_preference, push_subscription). - `scripts/vapid-generate.ts`: VAPID keypair generator (`bun run vapid:generate`). ## Related - [Queues](/api/queues/): the BullMQ shape this builds on. - [Email](/api/email/): the email channel uses the same dispatch. - [Audit log](/api/audit-log/): the ergonomic pattern this notification dispatcher mirrors. # API template: overview > Bun + Elysia + Drizzle + Postgres + Valkey + BullMQ. The architectural shape and where each concern lives. import DocFileTree from "../../../components/DocFileTree"; import FeatureGrid from "../../../components/docs-kit/FeatureGrid"; import PageIntro from "../../../components/docs-kit/PageIntro"; import SignalGrid from "../../../components/docs-kit/SignalGrid"; The API layer owns security, data, and background work: auth, sessions, OAuth, email, queues, audit log, Stripe billing, structured logging, and an env validator that refuses to boot if anything is missing. ## How the layers split ```mermaid flowchart LR routes["routes.ts
HTTP · TypeBox · no DB"] service["service.ts
logic + Drizzle
no Elysia"] types["types.ts
shared interfaces"] routes --> service routes -.-> types service -.-> types ```

Each API feature splits into three files with one job each:{" "} routes.ts owns HTTP and TypeBox validation but never touches the database; service.ts owns business logic and Drizzle queries but never imports Elysia; types.ts holds the shared interfaces both read. Lint rules forbid the cross-imports that would blur the split.

A feature is three files with three jobs. Lint plugins forbid them from leaking into each other: a `*.routes.ts` that imports `drizzle-orm` fails the build, and a `*.service.ts` that imports Elysia's `t` does too. ## Design choices ## File layout A [feature folder](/reference/glossary#feature-folder) always looks like (for a hypothetical `posts` resource): The shipped `auth`, `users`, `accounts`, `billing`, `dashboard`, `admin`, `health`, `notifications` modules are framework. `widgets` is the only example domain feature, kept as the reference for the [account-scoped resource pattern](/api/multi-tenant/) (every read/write filters by `accountId`). Replace it with your own product domain. Add new resources with `bun run new:resource `; the scaffolder writes the four-file anatomy and wires it into `config/routes.ts` so you can't forget a step. ## Cross-cutting concerns ## Lint as the contract The architecture is held in place by a family of [custom ESLint plugins](/architecture/lint-as-contract/). `bun run validate` is the merge gate. ## Source [`apps/api`](https://github.com/boringstack-xyz/boringstack/tree/main/apps/api) on GitHub. Start in `src/api/` for the feature shape; `src/config/` for the boot wiring. ## Related - [Lint as the contract](/architecture/lint-as-contract/); the family of plugins that keep these layers apart. - [lint:meta rules](/architecture/lint-meta/); static repo guardrails under `scripts/lint-meta/`. - [Scripts & tooling](/reference/scripts-tooling/); command → script map for apps/api. - [Authentication](/api/auth/); cookie sessions and refresh on the same spine. - [ACL & feature resolution](/api/acl/); server-authoritative permissions and plan gates. - [Multi-tenant model](/api/multi-tenant/); accountId as the scoping primitive. - [Env validator](/api/env-validator/); the boot guard refusing misconfigured deploys. # Queues > BullMQ on Valkey (Redis-protocol) for background jobs. One QueueManager owns lifecycle; dispatch falls back to inline execution when queues are off. import DocFileTree from "../../../components/DocFileTree"; import FeatureGrid from "../../../components/docs-kit/FeatureGrid"; import PageIntro from "../../../components/docs-kit/PageIntro"; Background work uses BullMQ over the same Valkey the cache lives on. A single QueueManager owns queues and workers, so request handlers dispatch intent without knowing whether the work runs inline, locally, or in a worker. The mental model: - A **queue** is a named work buffer in Valkey. - A **worker** is a long-lived process that pulls jobs off the queue and runs them. - A **`QueueManager`** is a process-singleton that owns all queues + workers, so application code only ever talks to one object. When `QUEUES_ENABLED=false`, every dispatch helper falls back to inline execution. Dev and tests run without a worker process. ## How a job runs ```mermaid sequenceDiagram participant Producer participant Manager as QueueManager participant Valkey participant Worker Producer->>Manager: enqueueX(data) Manager->>Valkey: ZADD with retry config Valkey-->>Worker: next job Worker->>Worker: process(data) alt success Worker->>Valkey: mark complete (TTL 1h) else failure Worker->>Valkey: schedule retry (exp backoff) Note over Valkey,Worker: up to 5 attempts, then dead-letter end ``` ## Design choices ## The shape of a queue Every queue is a small directory under `src/queues//`: The reference implementation is `email-delivery`; it's the simplest worker that exists (render a template, hand off to the email provider), so it's a good copy-target. ## QueueManager is the seam Application code never imports `Queue` directly. It calls `manager.enqueueX(...)`. Why: - One place to enforce retry/cleanup defaults across queues. - Graceful shutdown: `manager.close()` shuts every worker + queue in parallel; signal handlers only know about the manager. - Admin observability: `getStats()` returns waiting/active/completed/failed/delayed/paused counts for every managed queue. A new queue therefore needs four additions to `QueueManager`: the constructor input, an `enqueue()` method, an entry in `getStats()`, and a `close()` line. The lint plugin catches workers that omit a `failed` handler or skip retry config. ## Idempotency BullMQ retries on failure. If a worker dies after writing to the database but before marking the job complete, the same job runs again. Workers must be idempotent. Three patterns: - Natural keys. "Send verification email for `userId=X`, `token=Y`" is idempotent: re-sending the same token is harmless. - UNIQUE constraints plus catch. A second `INSERT` of the same audit row fails on the constraint; the worker treats unique-violation as success. - Check-then-do, transactional. Read state, decide if work is still needed, write atomically. Using BullMQ's `jobId` for deduplication only protects against simultaneous duplicate enqueues; not retries. ## Adding a queue 1. Create `src/queues//` following the per-queue directory pattern (constants, types, queue, worker, setup). 2. Add it to `QueueManager`'s constructor, an `enqueue()` method, `getStats()`, and `close()`. 3. Call `manager.enqueue(...)` from the producer. ## Dashboard `WITH_BULLMQ=1` in the infra stack brings up [Bull-board](https://github.com/felixmosh/bull-board) at `http://bullmq.localhost`; jobs by state, retry timelines, manual retry/discard. Dev-only; not exposed in the prod profile. See [Profiles & overlays](/infra/profiles-and-overlays/). ## Lint coverage [`@boring-stack-pkg/eslint-plugin-bullmq`](https://www.npmjs.com/package/@boring-stack-pkg/eslint-plugin-bullmq) catches the common foot-guns: workers that don't handle `failed`, jobs that mutate shared state without idempotency, missing retry config. ## Source [`src/queues/`](https://github.com/boringstack-xyz/boringstack/tree/main/apps/api/src/queues) on GitHub. [`src/config/setup-queues.ts`](https://github.com/boringstack-xyz/boringstack/blob/main/apps/api/src/config/setup-queues.ts) wires the manager into boot. ## Related - [Email](/api/email/); the producer side; `sendTemplate()` goes through `email-delivery` when queues are on. - [Lint as the contract](/architecture/lint-as-contract/); why machine-checked queue patterns matter. # Agent docs as a navigation index > AGENTS.md is a one-page table of pointers. Each row links to one focused guide under docs/agents/. Agents read only what the task needs. import PageIntro from "../../../components/docs-kit/PageIntro"; import SignalGrid from "../../../components/docs-kit/SignalGrid"; import DocCallout from "../../../components/DocCallout.tsx"; `AGENTS.md` is the primary file Claude and other agentic coding AI assistants read upon session initialization. BoringStack ships it as a highly focused navigation index, preventing context bloat and keeping agent edits isolated. `AGENTS.md` avoids the trap of the giant, thousand-line document that fights agents the same way a massive file fights humans: too much context to scan, too much irrelevant detail next to the bit you need, and rapid drift. Instead, it is a **navigation index** pointing to focused topic guides under `docs/agents/`. ## Shape `AGENTS.md` is one table. One row per topic. One link per row. Nothing else. ```md # Patterns: index `bun run check` is the oracle. If anything below disagrees with what `check` says, the lint config wins. Flag the drift. ## Deep dives | When you're doing this | Read this | | ---------------------------------------------- | --------------------------------- | | Touching schema, writing service queries | [drizzle](docs/agents/drizzle.md) | | Throwing in a service; wrapping a caught error | [errors](docs/agents/errors.md) | | Writing a `logger.*` call; PII masking | [logging](docs/agents/logging.md) | | Adding a BullMQ job; touching `src/queues/` | [queues](docs/agents/queues.md) | | Writing tests, fixing failures | [testing](docs/agents/testing.md) | ``` Each `docs/agents/.md` is single-concern: the one rule, the one idiom, the one example, the one anti-pattern. No "context" section, no glossary, no overview prose. The reader is already in the task; the file is the answer. ## Why a table, not paragraphs A paragraph pointer ("Touching Drizzle? Read `docs/agents/drizzle.md`, which covers schema conventions, the `db.transaction(tx)` pattern, raw-SQL bans...") looks helpful and reads as noise. Every paragraph below the fold pushes the rule the agent actually needs further away. A two-column table reduces each entry to **trigger → file**. The agent scans, picks one, loads it. The index never grows beyond a screen, no matter how many topics live underneath. ## Why split files at all Splitting also enforces a useful discipline: each guide answers _one_ question. If a guide grows past a screen, the topic was actually two topics. Split it again. ## Naming the file by the trigger, not the noun The "When you're doing this" column matters more than the topic name. Agents arrive with a task ("I need to add a webhook handler"), not a topic ("I want to learn billing"). The triggers in the table match the verbs an agent would write in their own plan. This is why the table works as a navigation index but a sidebar of topic names doesn't: a sidebar lists nouns, the agent needs verbs. ## Where the rules actually live Even the focused guides don't _enforce_ anything. The lint config is the contract. See [Lint as the contract](/architecture/lint-as-contract/). The docs explain the _why_; the plugins block the _what_. So `AGENTS.md` answers "which page is the why?" and nothing else. ## Where to look in this repo - `apps/api/AGENTS.md`: 30 lines, one table, points at 16 guides under `apps/api/docs/agents/`. - `apps/ui/AGENTS.md`: same shape, 16 guides under `apps/ui/docs/agents/`. If you fork BoringStack and add a new pattern, the cost is one row in the table plus one new file. Never edit prose-heavy sections in `AGENTS.md`; there are none to edit. ## Related - [Lint as the contract](/architecture/lint-as-contract/) - [Separation of concerns](/architecture/separation-of-concerns/) - [Repository layout](/architecture/monorepo-layout/) # Background work > Email, notifications, and BullMQ jobs inside the API. Ready on day one, with a clear path when a piece needs its own process. import PageIntro from "../../../components/docs-kit/PageIntro"; import SignalGrid from "../../../components/docs-kit/SignalGrid"; import FaqGroup from "../../../components/FaqGroup.tsx"; import FaqItem from "../../../components/FaqItem.tsx"; Products need work off the HTTP critical path: email, notifications, retries. BoringStack puts that work in the API with BullMQ on Valkey, shared queue patterns, and dispatch helpers. You ship events and mail without standing up a separate job platform first. ## What ships ## Notifications flow ```mermaid flowchart LR app[Application code] --> reg[eventRegistry] reg --> dispatch[notification-dispatch queue] dispatch --> ch{channels} ch --> inApp[in-app] ch --> email[email] ch --> sse[SSE via Valkey pub/sub] ```

Notification flow: application code emits typed events through the eventRegistry; the notification-dispatch queue resolves channels; dispatched events fan out to three sinks: an in-app row in Postgres, an email template through the email provider, and a live SSE push via Valkey pub/sub.

1. **Define an event.** `defineNotificationEvent` registers type, payload shape, and which channels apply. 2. **Emit.** Application code triggers the event; preferences and dedup run before enqueue. 3. **Dispatch job.** The `notification-dispatch` worker resolves channels and delivers (in-app row, email template, live SSE to connected clients). 4. **Maintenance.** A separate maintenance queue handles retention and housekeeping. Extension lives under `src/lib/notifications/` (`events/`, `dispatch/`, `channels/`, `preferences/`, `pubsub/`, `dedup.service.ts`). Add an event with the scaffold script, register channels, enqueue through the dispatcher. Mail follows the same pattern via [email-delivery](/api/queues/). ## Email and audit [Email](/api/email/) uses the same queue-or-inline pattern: precompiled Handlebars JSON, then the configured provider (Cloudflare, Resend, SendGrid, SMTP, noop). [Audit log](/api/audit-log/) appends security-relevant events to a dedicated Postgres schema (`audit` namespace). Fire-and-forget from the API; query and retention are yours to extend. ## When to extract a piece In-process is the default. Move work to a dedicated worker or service when: - Dispatch or send rate needs isolation from API request latency. - Another group owns delivery infrastructure. - Background work needs its own scaling, deploy, or failure domain. - Data or processing must run in a segregated environment. Keep job shapes and contracts stable (payload types, idempotency rules) so API producers change little. BullMQ job names, channel interfaces, and OpenAPI-facing behavior stay the same; only where the worker runs changes. ## Related - [Queues](/api/queues/) - [Why BoringStack](/architecture/why-boringstack/) - [Lint as the contract](/architecture/lint-as-contract/) # Decision log > Every "boring" choice in BoringStack written up the same way: what, why, what we gave up, when we'd reconsider. Short. Cross-linked. import PageIntro from "../../../components/docs-kit/PageIntro"; import FaqGroup from "../../../components/FaqGroup.tsx"; import FaqItem from "../../../components/FaqItem.tsx"; 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. ## Data plane **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. **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. **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 **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](/ui/openapi-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. **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. **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 **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](/architecture/why-boringstack/#cost-calc-title). **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). **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. **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 **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. **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. **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 **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. **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. **Choice.** [What's Up Docker](/runbooks/image-updates/) 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 - [Why BoringStack](/architecture/why-boringstack/); the broader thesis these decisions support. - [Cost methodology](/reference/cost-methodology/); the dollar consequence of these picks. - [Stack at a glance](/architecture/stack/); the dependency inventory. - [Lint as the contract](/architecture/lint-as-contract/); the architecture-as-lint pick, written out. # Lint as the contract > Custom ESLint plugins encode the architecture. Machine-checked rules keep the shape intact through refactors and AI-assisted edits. import { Aside } from "@astrojs/starlight/components"; import CommandRun from "../../../components/docs-kit/CommandRun"; import FeatureGrid from "../../../components/docs-kit/FeatureGrid"; import FaqGroup from "../../../components/FaqGroup.tsx"; import FaqItem from "../../../components/FaqItem.tsx"; import PageIntro from "../../../components/docs-kit/PageIntro"; import SignalGrid from "../../../components/docs-kit/SignalGrid"; This is the load-bearing piece of Why BoringStack: architecture lives in tooling, with prose for context. The docs explain intent; lint makes drift visible before review. ## The problem Architecture docs rot. New teammates miss local conventions. Agents can generate plausible code in the wrong layer. Reviewers should spend their time on product behavior, not rediscovering that a route now contains business logic or a component reads environment state directly. BoringStack treats lint as the executable part of the architecture. `AGENTS.md`, `CLAUDE.md`, and `AGENT_CONTRACT.md` explain intent; custom ESLint plugins make the important parts fail the build. ## How it works Every template ships prose contracts and lint gates together. The prose says where routes go, when to use a service, how to write a Drizzle query, why `process.env` is forbidden outside the env validator. The plugins turn those patterns into parseable, repeatable errors. `bun run validate` / `bun run validate` is the merge gate: if lint fails, the diff does not ship. Prose is the why; lint is what ships. ## What gets enforced ## Plugin inventory Use this as the reference catalog after the mental model makes sense. ### Shared across apps/api + apps/ui [`@boring-stack-pkg/eslint-plugin-resource-architecture`](https://www.npmjs.com/package/@boring-stack-pkg/eslint-plugin-resource-architecture): per-feature route/service/types split. No business logic in routes; no HTTP in services. [`@boring-stack-pkg/eslint-plugin-module-boundaries`](https://www.npmjs.com/package/@boring-stack-pkg/eslint-plugin-module-boundaries): single-semantic-module files. No mixed-concern dumps. [`@boring-stack-pkg/eslint-plugin-structured-logging`](https://www.npmjs.com/package/@boring-stack-pkg/eslint-plugin-structured-logging): Pino discipline. No `console.log`, no string-interpolated log lines, no logging secrets. The `typed-event-names` rule validates every `logger. {level}({event})` literal against the canonical `LOG_EVENTS` const tuple so log events stay a closed set. [`@boring-stack-pkg/eslint-plugin-env-access`](https://www.npmjs.com/package/@boring-stack-pkg/eslint-plugin-env-access): `process.env` / `import.meta.env` only inside the env validator; everywhere else uses validated config. [`@boring-stack-pkg/eslint-plugin-test-conventions`](https://www.npmjs.com/package/@boring-stack-pkg/eslint-plugin-test-conventions): `tests/` mirrors `src/`; no orphan tests. [`@boring-stack-pkg/eslint-plugin-code-flow`](https://www.npmjs.com/package/@boring-stack-pkg/eslint-plugin-code-flow): control-flow discipline. `prefer-early-return` (no needless nesting after a guard) and `no-template-trim-empty-ternary` (bans the inline `${a} ${b} `.trim() === "" ? fallback : ...` anti-pattern: extract to a named util like `buildDisplayName(...)` so the construction is testable in one place); also enforces blank-line padding before `throw` and `return` so the exit branch is visually distinct. [`@boring-stack-pkg/eslint-plugin-comment-hygiene`](https://www.npmjs.com/package/@boring-stack-pkg/eslint-plugin-comment-hygiene): `no-narration-comments` flags AI-generated narration like "Here we…" / "Now we…" / "Let's…"; `no-pr-reference-comments` flags `#123` / `PR 42` / GitHub URLs embedded in code (they belong in the PR description, not the source). ### Built-in + third-party rules wired at the same gate Two non-custom rules earn their place alongside the `@boring-stack-pkg` plugins: [`@eslint-community/eslint-plugin-eslint-comments`](https://github.com/eslint-community/eslint-plugin-eslint-comments): `no-use` is set to `error` with `{ allow: [] }`. Zero inline disables; defence-in-depth on top of the source-text ban that `lint:meta` already enforces. Built-in ESLint rule. Any `//`-style block that spans three or more consecutive lines must be a single `/* … */` block. Auto-fixable; keeps WHY-comments visually distinct from inline notes. Built-in ESLint rule with a project-specific selector. Every call site must use the `now()` util from `src/lib/time/now.ts` so timestamps have a single mockable source and a single place to change formatting if the contract ever shifts. ### apps/api only [`@boring-stack-pkg/eslint-plugin-elysia`](https://www.npmjs.com/package/@boring-stack-pkg/eslint-plugin-elysia): TypeBox on every route, no untyped handlers, plugin registration patterns. `route-must-check-ability` requires every handler that destructures `membership` from context to authorize explicitly: either read `membership.role` or call `requireAbility` / `enforceLimit`. [`@boring-stack-pkg/eslint-plugin-drizzle-conventions`](https://www.npmjs.com/package/@boring-stack-pkg/eslint-plugin-drizzle-conventions): schema and query patterns, index naming, `with` usage. `account-scoped-tables-require-where` requires every query against an account-scoped table (`widgets`, `accountMemberships`, `accountInvitations`, `accountFeatureOverrides`, `accountPlans`) to filter by `accountId` (tenant isolation enforced at the query level). [`@boring-stack-pkg/eslint-plugin-db-transactions`](https://www.npmjs.com/package/@boring-stack-pkg/eslint-plugin-db-transactions): multi-write paths inside a transaction. [`@boring-stack-pkg/eslint-plugin-jwt-cookies`](https://www.npmjs.com/package/@boring-stack-pkg/eslint-plugin-jwt-cookies): cookie attributes and JWT verify call sites. [`@boring-stack-pkg/eslint-plugin-oauth-security`](https://www.npmjs.com/package/@boring-stack-pkg/eslint-plugin-oauth-security): OAuth state + PKCE on the server flow. [`@boring-stack-pkg/eslint-plugin-bullmq`](https://www.npmjs.com/package/@boring-stack-pkg/eslint-plugin-bullmq): queue/worker idempotency, failed handlers, name discipline. [`@boring-stack-pkg/eslint-plugin-cache-keys`](https://www.npmjs.com/package/@boring-stack-pkg/eslint-plugin-cache-keys): namespaced cache keys and TTL discipline. [`@boring-stack-pkg/eslint-plugin-audit-log`](https://www.npmjs.com/package/@boring-stack-pkg/eslint-plugin-audit-log): audit writes on flagged mutations. [`@boring-stack-pkg/eslint-plugin-stripe-webhooks`](https://www.npmjs.com/package/@boring-stack-pkg/eslint-plugin-stripe-webhooks): signature verification, idempotency, raw-body access. ### apps/ui only [`@boring-stack-pkg/eslint-plugin-react-component-architecture`](https://www.npmjs.com/package/@boring-stack-pkg/eslint-plugin-react-component-architecture): component anatomy, hooks, `className` discipline, file naming, prop ordering. The `max-hooks-per-file` rule caps top-level hooks per file (default 4) so god-modules like a 7-hook `*.queries.ts` either get split or fail the gate. [`@boring-stack-pkg/eslint-plugin-tanstack-query-cache`](https://www.npmjs.com/package/@boring-stack-pkg/eslint-plugin-tanstack-query-cache): on `*.queries.ts`, when keys are built as `[...PREFIX, …]`, cache writes must use matcher-style APIs (`setQueriesData`, `cancelQueries` with `exact: false` / `predicate`, etc.) instead of `setQueryData` / `getQueryData` on the bare prefix alone; otherwise only one entry updates while hooks still read the spread key. [`@boring-stack-pkg/eslint-plugin-i18n-keys`](https://www.npmjs.com/package/@boring-stack-pkg/eslint-plugin-i18n-keys): **static** string keys in `t("…")` / `i18n.t("…")` must exist in the canonical English catalog (`src/lib/i18n/locales/en/common.json` in the template). Catches typos and orphan keys at lint time. ## How they're wired Each template's flat ESLint config (`eslint.config.mjs` in apps/ui, `eslint.config.js` in apps/api) imports these plugins from `node_modules/`. They are **devDependencies** installed from npm as exact-version `@boring-stack-pkg/eslint-plugin-*` pins; source lives in the [`boringstack-xyz/eslint-plugins`](https://github.com/boringstack-xyz/eslint-plugins) monorepo, releases run through [Changesets](https://github.com/changesets/changesets), and every published version carries an OIDC-signed provenance attestation. The 7-day `minimumReleaseAge` quarantine that protects the rest of the dependency tree is exempted for the `@boring-stack-pkg/*` scope (it's our own code; the quarantine guards against compromised upstream publishes we don't control). The plugins ship their own `recommended` configs where applicable; we extend those and keep severities at `error` so the merge gate has real teeth. Where `lint:meta` is wired (`bun run lint:meta` / `bun run lint:meta`), it adds extra contract checks on top of ESLint — package.json pin parity, CI/pre-push parity, env cascade drift, source-text bans, and test-sibling requirements. The full machine-readable catalog lives on [lint:meta rules](/architecture/lint-meta/); implementation is under `scripts/lint-meta/cli.ts` with fixture-based tests in `tests/lint-meta/`. For which script runs each merge-gate command, see [Scripts & tooling](/reference/scripts-tooling/). The same merge gate runs in three places so a broken change can't slip through the cracks. A husky `pre-commit` hook runs `bun run lint-staged` against the staged files, the cheap, scoped check on every commit. A husky `pre-push` hook (installed via `prepare` on `bun run install` / `bun install`; for `infra/compose` and `.github`, run `./scripts/install-hooks.sh` once) then runs the full `validate` locally before anything leaves your machine: typecheck, lint, knip, tests, build, `osv-scanner` against the lockfile, and (when workspace apps are present) an OpenAPI schema drift check across apps/api ↔ apps/ui. CI re-runs the same gate so a `--no-verify` push is still caught. [Knip](https://knip.dev) sits alongside the lint plugins, surfacing unused files, unused exports, and unused dependencies. It runs as part of `validate` so dead code can't drift in unnoticed. ## Why this matters for agent-driven development Lint turns architecture into a closed loop: code is written, violations surface as parseable errors, the fix is specific (wrong layer, missing schema, env read outside the validator). Review time stays on product logic instead of rediscovering folder rules. For humans and agents alike, docs explain and lint enforces. Both matter; lint is what keeps the merge gate honest. ## Contributing back The plugins are MIT-licensed and developed in the open. If you find a gap, file an issue or PR against the relevant plugin repo (linked above). Rules that prove themselves in production end up promoted to `recommended` configs. ## Related - [Why BoringStack](/architecture/why-boringstack/); where this fits the broader product thesis. - [lint:meta rules](/architecture/lint-meta/); static repo guardrails that run alongside ESLint. - [Scripts & tooling](/reference/scripts-tooling/); which script file each merge-gate command runs. - [Agent docs as an index](/architecture/agent-docs/); the docs side of the same loop. `AGENTS.md` is a navigation table over `docs/agents/`. - [Stack at a glance](/architecture/stack/); the dependency inventory. - [API template overview](/api/overview/); backend layers these rules protect. - [Architecture rules](/ui/architecture-rules/); the UI-specific component anatomy. # lint:meta rules > Static repo guardrails that run alongside ESLint — package.json pins, CI parity, env cascade drift, source-text bans, and test coverage contracts. import { Aside } from "@astrojs/starlight/components"; import PageIntro from "../../../components/docs-kit/PageIntro"; import LintMetaCatalog from "../../../components/docs-kit/LintMetaCatalog"; ESLint enforces architecture inside TypeScript modules. lint:meta{" "} catches repo-level drift ESLint cannot see: unpinned GitHub Actions, env cascade gaps, forbidden inline disables, cross-repo imports, and missing test siblings. It runs inside bun run check / bun run check. ## Merge gate stack ``` typecheck → ESLint → lint:meta → knip → tests ``` ## UI-only rules The apps/ui catalog includes rules that only apply to the SPA repo: - `no-cross-repo-import` — **CI-critical**; UI imports must not reach into backend or infra source - `modulepreload-size-limit-coverage` — bundle size-limit config must cover modulepreload chunks - `no-dark-variant`, `no-dangerous-html`, `env-access`, `no-raw-fetch` — source-text bans for Vite/React patterns ## API-only rules The apps/api catalog adds backend test contracts: - `routes-require-test-sibling` — every `*.routes.ts` needs a matching `tests/api/**/*.routes.test.ts` - `touch-tests-too` — opt-in via `LINT_META_TOUCHED_BASE` for diff-aware test enforcement ## Shared rules Both templates share supply-chain, CI, env, artifact, and config rules (exact deps, pre-push CI parity, engine pin parity, generated artifact banners, forbidden inline disables, raw role literals, logic-file test siblings). ## apps/ui catalog ## apps/api catalog ## Adding a rule 1. Implement `IMetaRule` under `scripts/lint-meta/rules//` 2. Register it in `scripts/lint-meta/registry.ts` 3. Run `bun run generate:lint-meta-docs` (ui) or `bun run generate:lint-meta-docs` (api) 4. Run the boringstack docs generators and commit the updated JSON catalogs 5. Add a test under `tests/lint-meta/` ## Related - [Lint as the contract](/architecture/lint-as-contract/) — ESLint plugin inventory - [Scripts & tooling](/reference/scripts-tooling/) — which script runs `lint:meta` - [Commands cheatsheet](/reference/commands/) — day-to-day workflow commands # Monorepo layout > How the BoringStack monorepo is organized — apps, infra, docs, and cross-app contracts. import { Aside } from "@astrojs/starlight/components"; import DocFileTree from "../../../components/DocFileTree"; import PageIntro from "../../../components/docs-kit/PageIntro"; import SignalGrid from "../../../components/docs-kit/SignalGrid"; import FaqGroup from "../../../components/FaqGroup.tsx"; import FaqItem from "../../../components/FaqItem.tsx"; BoringStack ships as a single monorepo. API, UI, docs site, and Compose infra live in one tree with path-filtered CI and root-level `bun run regen` / `bun run check` for cross-app contracts. The runtime is composed from `apps/api`, `apps/ui`, and `infra/compose`. Optional VPS provisioning lives in `infra/bootstrap`. The docs site source is `apps/docs` (deployed to boringstack.xyz). Each app keeps its own `package.json`, lockfile, and lint config. CI workflows live at the repo root (`.github/workflows/`) with path filters such as `apps/api/**` and `infra/compose/**`. ## Compose wiring ## What lives where - HTTP routes, auth, OAuth, password hashing - Email send and provider abstraction - BullMQ jobs and queue workers - Audit log writes - Drizzle schema and migrations - Pages, components, queries, stores, routes - OpenAPI client (from `/swagger/json`) - i18n (en, de), shadcn/ui primitives - Storybook, Playwright e2e - docker-compose YAMLs and profile overlays - Prometheus, Grafana, Loki, Promtail configs - Traefik labels, ACME, security middlewares - Backup scripts and runbooks - boringstack.xyz Starlight site - Generated lint-meta and scripts tooling catalogs - Hetzner provisioning, Cloudflare DNS, cloud-init ## Cross-app contracts From the **repo root**: ```bash bun run regen # ACL types → OpenAPI schema → RULES.md → docs JSON bun run check # drift checks (api on :3000 required for OpenAPI check) ``` | Contract | Producer | Consumer | Generated artifact | |----------|----------|----------|-------------------| | ACL types | `apps/api` | `apps/ui` | `apps/ui/src/lib/acl/acl.types.generated.ts` | | OpenAPI | `apps/api` `/swagger/json` | `apps/ui` | `apps/ui/src/lib/api/schema.d.ts` | | lint-meta | each app `scripts/lint-meta/` | committed `RULES.md` | per app | | Docs catalogs | api + ui scripts/README | `apps/docs` | `src/data/*.json` | CI runs the same checks from one checkout on GitHub `main`. ## The contract between API and UI The API exposes its OpenAPI document at `/swagger/json`. The UI ships `bun run generate:api` that reads that document and rewrites `src/lib/api/schema.d.ts`. When the API changes routes, the UI either fails typecheck or you run `bun run regen` at the repo root. See [OpenAPI client](/ui/openapi-client/) for the full flow. ## The contract between infra and the apps `infra/compose/compose/docker-compose.yml` defines `api-dev` / `api` / `ui-dev` / `ui` services whose build context is `apps/api` and `apps/ui`. Env vars are documented in `compose/api.dev.env.example` and `compose/api.prod.env.example`. In prod the default `image:` reference pulls a pre-built image from GHCR, published by the release workflows. See [Deployment](/topics/deployment/). ## Related - [Separation of concerns](/architecture/separation-of-concerns/) - [Why BoringStack](/architecture/why-boringstack/) - [Commands cheatsheet](/reference/commands/) # Separation of concerns > API, UI, and infra each own one job. Clear boundaries, independent releases, and an OpenAPI contract between the app layers. import PageIntro from "../../../components/docs-kit/PageIntro"; import SignalGrid from "../../../components/docs-kit/SignalGrid"; import FeatureGrid from "../../../components/docs-kit/FeatureGrid"; BoringStack splits the runtime into three layers. Each layer has one job, owns its own deployment pipeline, and talks to the others through typed boundaries. ## What each layer delivers ## How the layers connect ```mermaid flowchart LR browser[Browser SPA] api[API Bun Elysia] data[(Postgres)] cache[(Valkey)] browser -->|HTTPS JSON OpenAPI| api api --> data api --> cache ```

Left to right: a browser SPA calls the Bun + Elysia API over HTTPS using a typed OpenAPI contract; the API alone talks to Postgres for durable state and to Valkey for cache and queue work. No browser-to-database path exists.

The API publishes OpenAPI at `/swagger/json`. The UI runs `bun run generate:api` to refresh `schema.d.ts`. `openapi-fetch` rejects invalid paths and bodies at compile time. See [OpenAPI client](/ui/openapi-client/) and [Repository layout](/architecture/monorepo-layout/) for wiring. In production, Traefik serves the SPA and API on one apex host (`/` and `/api/*`). That simplifies TLS and cookies. The app workspaces stay isolated, and the Docker images stay separate. ## What you gain ## Related - [Why BoringStack](/architecture/why-boringstack/) - [Repository layout](/architecture/monorepo-layout/) - [Background work](/architecture/background-work/) # Stack at a glance > Every dependency, every choice, grouped by template. import { Tabs, TabItem } from "@astrojs/starlight/components"; import FeatureGrid from "../../../components/docs-kit/FeatureGrid"; import PageIntro from "../../../components/docs-kit/PageIntro"; import FaqGroup from "../../../components/FaqGroup.tsx"; import FaqItem from "../../../components/FaqItem.tsx"; Runtime and dev dependencies, what each does, and why each piece exists. BoringStack is built from proven parts and wired with contracts so humans and AI agents can change one layer without guessing about the others. ## Summary ## Full inventory Bun-native APIs, fastest install and cold boot. TypeBox-typed routes, OpenAPI auto-emit. TS-first, SQL-shaped, real migrations. Durable, well-tooled. OSS Redis-protocol fork (BSD-3); drop-in for Redis clients. Canonical Valkey job queue. bcryptjs + short-lived JWT access cookies + DB-backed refresh sessions + Arctic (OAuth). Cloudflare Email (default), Resend, SendGrid, SMTP, noop. SMTP works with Mailpit in dev. @sentry/bun → hosted Sentry or self-hosted GlitchTip. OpenAI SDK, Anthropic SDK, or noop. `OPENAI_BASE_URL` for compatible endpoints. Pino + pino-pretty. JSON in prod. Handlebars precompiled to JSON at build; zero runtime parse. Knip gates `bun run validate`. Fast HMR, native ESM, dev proxy for same-origin `/api/*`. Current React with compiler support. Documented cache invariants. Small, no provider chain. React Hook Form + Zod. React Router. shadcn/ui + Radix; you own the primitives. Tailwind + `@theme` tokens. openapi-fetch + generated schema from live API. @sentry/react. react-i18next; en + de. Vitest, Testing Library, Playwright. Playwright snapshots per platform. Component catalog with controls. Knip gates `bun run check`. Docker labels, ACME, same-origin path routing. Let's Encrypt via ACME HTTP-01. Prometheus + standard exporters. Loki + Promtail; per-container labels. Grafana; drop JSON into `compose/grafana/dashboards/`. Alertmanager. GlitchTip on base Postgres + Valkey. WUD hybrid in prod: app images auto-deploy, base images notify-only. Mailpit; `WITH_MAILPIT=1`. bull-board; dev only. rclone; off-site, retention-managed. UFW example script on Hetzner cloud firewall. Terraform-compatible fork (MPL). EU-friendly API and pricing. Cloudflare apex + www; proxied; same-origin routing. Hetzner cloud firewall; Cloudflare IP ranges at plan time. cloud-init + `bootstrap.sh`; clone infra, `compose pull && up -d`. rclone via cron at apply time. ## Architecture-enforcement plugins Each app workspace ships custom ESLint plugins (all open-sourced) that codify the patterns and stop drift at the lint gate. [Full inventory with GitHub links →](/architecture/lint-as-contract/) ## Related - [Why BoringStack](/architecture/why-boringstack/); the decision guide behind these choices. - [Repository layout](/architecture/monorepo-layout/); how the workspaces fit together. - [Deployment](/topics/deployment/); how the runtime choices ship. # Why BoringStack > Proven pieces wired with clear layers and lint that holds the architecture in place. import CostCalculator from "../../../components/landing/CostCalculator.tsx"; import DocCallout from "../../../components/DocCallout.tsx"; import FeatureGrid from "../../../components/docs-kit/FeatureGrid"; import PageIntro from "../../../components/docs-kit/PageIntro"; import SignalGrid from "../../../components/docs-kit/SignalGrid"; The MIT templates compose the full stack: apps/api (Bun, Elysia, Drizzle), apps/ui (Vite, React, generated OpenAPI client), infra/compose (Postgres, Valkey, Traefik), and optional infra/bootstrap for VPS bootstrap. Auth, billing, queues, email, env validation, and deploy scripts are wired and documented; custom ESLint plugins enforce folder and contract shape in CI. ## Fit check | BoringStack is a good fit if... | It is probably the wrong fit if... | | ------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------- | | You need accounts, auth, billing, email, and background jobs with Postgres + Valkey on your own VPS. | You only need a static site or a no-code prototype with no custom backend. | | You want Postgres, Valkey, Traefik, and Docker Compose under your control. | You want a serverless-first Vercel/Netlify path where the platform owns runtime decisions. | | You want API and UI as separate deployable apps with an OpenAPI contract between them. | You prefer a single full-stack framework with one app boundary. | | You want ESLint rules and validate CI to enforce architecture, including agent edits. | You rely on conventions, README prose, and review alone. | | You want Compose on one VPS now, with an optional path to managed Postgres or Kubernetes later. | You need Kubernetes, multi-region, or managed-everything infrastructure on day one. | ## Problems it solves ## What “boring” means The name is deliberate. Boring means proven primitives wired with explicit boundaries. Postgres, HTTP APIs, browsers, Docker Compose, TLS, Redis-protocol queues: pieces with long production track records. They are linked in a layout you can operate: compose/dev.sh locally, compose/prod.sh on a VPS, OpenTofu optional for first boot. The inventory of choices is on [Stack at a glance](/architecture/stack/). This page explains repo boundaries and default trade-offs. ## COGS-first by default Many starters defer infra cost until traffic grows. BoringStack defaults to self-hosted Postgres, Valkey, and Traefik so monthly COGS stays tied to VPS size, not platform usage meters. The core runtime is open source and self-hostable: - Data plane: Postgres + Valkey. - Edge and deploy: Docker Compose + Traefik on a VPS. - Background work: BullMQ on Valkey. - Observability: Prometheus, Grafana, Loki, Promtail, and Alertmanager as an opt-in overlay. - Error tracking: hosted Sentry if you want it, self-hosted GlitchTip if margin matters. - Email development: Mailpit locally; Cloudflare Email, SMTP, Resend, or SendGrid in production. - Provisioning: OpenTofu instead of click-by-click cloud setup. That does not mean "never use SaaS." Stripe, Cloudflare, Sentry, Resend, SendGrid, Neon, Supabase, and other managed services can all make sense. BoringStack does not force your core application onto a platform meter before you have revenue. You can start nearly free on one capable VPS, then buy managed services only where the trade-off is worth it. ## Compared with common alternatives | Decision point | BoringStack | Typical Vercel/serverless starter | Hosted backend starter | One-off SaaS boilerplate | | ------------------------- | ----------------------------------------------------- | ---------------------------------------------- | ---------------------------------------------- | --------------------------------------- | | Deployment path | Single-host VPS first; optional OpenTofu bootstrap | Platform-first; VPS path is usually DIY | Hosted service first | Varies by vendor | | Core data plane | Postgres + Valkey under your control | Usually external services | Hosted database/auth/storage | Varies | | COGS posture | Open-source core; one VPS can carry the whole app | Usage-based platform bill grows with traffic | Vendor pricing becomes part of the app shape | Varies | | API/UI contract | OpenAPI generated client | Often framework-coupled or hand-rolled | SDK/client generated by provider | Varies | | Auth/billing/queues/email | Wired as product infrastructure | Mostly app-specific assembly | Auth often built in; queues/billing/email vary | Often included, quality varies | | Architecture enforcement | Custom lint plugins and `validate` gates | Mostly conventions | Mostly provider boundaries | Usually prose and examples | | Best for | Teams that self-host Postgres/Valkey and want OpenAPI + lint gates | Fast frontend-heavy apps on a managed platform | Apps that fit the provider SDK and hosted DB model | Buying a finished opinionated app shell | The choice is not moral. BoringStack is intentionally biased toward ownership, explicit boundaries, low recurring infra cost, and code that remains understandable after many human and agent edits. ## The three layers ```mermaid flowchart TB subgraph ui [apps/ui] spa[Vite + React SPA] end subgraph api [apps/api] http[Bun + Elysia API] jobs[BullMQ workers] end subgraph infra [infra/compose] edge[Traefik TLS routing] data[(Postgres + Valkey)] end spa -->|OpenAPI JSON| http http --> data jobs --> data edge --> spa edge --> http ```

Three stacked layers: the apps/ui SPA sits above the apps/api (HTTP server plus BullMQ workers), which sits on the infra layer (Traefik routing in front of Postgres and Valkey). The SPA reaches the API over a typed OpenAPI contract; both the API and the workers read and write the same data plane.

### API Security, validation, persistence, background jobs, secrets, audit trail. The system of record lives here. ### UI Rendering, interaction, client state, i18n. Vite keeps local feedback fast. The UI calls the API through a generated, typed client. ### Infra TLS, routing, databases, queue store, optional metrics and logs. In production, same-origin path routing (`/` for the SPA, `/api/*` for the API) keeps browser security simple. The code boundaries stay separate. [Separation of concerns](/architecture/separation-of-concerns/) goes deeper on what each layer delivers and how releases stay independent. ## Judgment baked in The templates come from codebases that have been in production for many years. File layout, feature anatomy, env discipline, queue patterns, and deploy defaults are decisions you would otherwise make again on every project. You extend what ships. You do not reinvent the spine. ## Lint as load-bearing architecture `AGENTS.md`, `CLAUDE.md`, and `AGENT_CONTRACT.md` explain intent. Custom ESLint plugins enforce it. Machine-checked rules survive refactors, new teammates, and agent-generated diffs. They cover where routes live, how env is read, and how queues are shaped. When lint fails, the fix is specific. Architecture lives in tooling. Prose is context. Read [Lint as the contract](/architecture/lint-as-contract/). The monorepo keeps each layer in a focused workspace. Edit a route, a page, or a compose overlay without pretending the layers are the same application. ## Room to grow Email, notifications, and other background work already have a home in the API. When volume, team boundaries, or compliance need a dedicated process, you extract a piece and keep the same job contracts. See [Background work](/architecture/background-work/). ## Related - [Quickstart](/quickstart/) - [Repository layout](/architecture/monorepo-layout/) - [Lint as the contract](/architecture/lint-as-contract/) # What's new > Recent changes to BoringStack: docs, templates, and tooling. Reverse-chronological; the top entry is the most recent. A log of substantial changes to the docs, templates, and tooling. For per-commit detail, see the git history of each [workspace app](https://github.com/boringstack-xyz). For the latest released images, see the [Image updates runbook](/runbooks/image-updates/). ## 2026-05 ### Cost & decisions - **[Cost calculator](/architecture/why-boringstack/#cost-calc-title) on Why BoringStack.** Four-stage tier picker with three solution categories (BoringStack VPS, Managed-PaaS bundle, Hosted SaaS platform). Numbers cite public rate cards; methodology lives on its own [reference page](/reference/cost-methodology/). - **[Decision log](/architecture/decisions/) shipped.** Twelve ADR-style entries covering every "boring" pick (Postgres, Valkey, Bun + Elysia, Drizzle, Compose, Traefik, Cloudflare Tunnel, OpenTofu, Astro for docs, Bun everywhere, architecture-as-lint, OpenAPI boundary, React, Mailpit, GlitchTip, WUD notify-only). ### Recipes - **[Recipes section](/recipes/add-stripe/) added** to the sidebar with four how-tos: Add Stripe Checkout, Add S3-compatible uploads, Add a background job, Add a service to Compose. Each follows the same Goal / Prereqs / Steps / Verify / What changes in code shape. - **"What you'll have in 10 minutes"** deliverables callout at the top of [Quickstart](/quickstart/) so readers see the payoff before the steps. ### Discoverability - **[`llms.txt`](/llms.txt) + [`llms-full.txt`](/llms-full.txt) + [`llms-small.txt`](/llms-small.txt)** now publish so AI assistants (Claude, ChatGPT, Perplexity, Cursor) can discover and consume the docs cleanly. Generated via `starlight-llms-txt` at build time. - **Broken-link CI workflow** added at `.github/workflows/docs-linkcheck.yml`. Runs `lychee` against the built site on every PR; fails on broken internal links, warns on flaky external 4xx/5xx. ### Docs polish - **Glossary links on first use.** Three high-traffic terms (`overlay`, `feature folder`, `view object`) now link to the [Glossary](/reference/glossary/) on first prose use across the docs. Anchors stable per term. - **Reading time on every page.** Each docs page now displays an estimated read time pill below the title, computed from the page body at build. - **Related navigation on every page.** Twelve pages that previously dead-ended (overview pages, runbooks, topics, reference) now end with a `## Related` block linking 3–5 adjacent pages. - **Changelog & 404.** This page, plus a custom 404 with suggested starting points and a search hint. ### Branding - **Emerald accent migration.** Site accent moved from amber `#fbbf24` to emerald `#4ade80` across Starlight tokens, custom CSS, Mermaid theme variables, the landing hero gradient, badges, and asides. Tip badges and asides reassigned to cyan so they stay distinct from the brand green; caution keeps amber for semantic warnings. - **Amber accent migration.** Site accent moved from violet `#8b5cf6` to amber `#fbbf24` across Starlight tokens, custom CSS, Mermaid theme variables, the landing hero gradient, badges, and asides. Light-mode contrast tuned to amber-600 / amber-950 ink. - **Server-rack icon.** Header logo and favicon replaced with a sharp, minimalistic server/rack SVG. - **Mermaid restyle.** Diagrams adopted the amber + cyan + pink brand palette with gradient borders and per-element accents. ### Positioning - **Homepage rewrite.** Splash hero, CTAs, fit-check table, and comparison row against managed-PaaS bundles. The 3-layer architecture diagram moved closer to the entry point. - **Quickstart slimmed.** The first-deploy production checklist moved to [Deployment](/topics/deployment/) so Quickstart stays a five-minute path. - **Lint as the contract restructured** from a plugin catalog into a narrative: problem, solution, feature grid, then the plugin inventory underneath. ## 2026-04 ### Architecture & content - **ACL & multi-tenant docs.** Server-authoritative permissions and the accountId scoping primitive each got their own page. - **Memory context docs.** New page covering the memory layer in the API template. - **Security pipeline docs.** osv-scanner, gitleaks, and CodeQL wiring explained alongside the supply-chain protections (seven-day minimum release age across all repos). - **Agent docs as an index.** `AGENTS.md` is now a navigation table over `docs/agents/`; the docs site mirrors the same shape. ### Infra - **`@boring-stack-pkg` namespace.** All custom ESLint plugins moved to the `@boring-stack-pkg/*` npm scope; docs and lint configs updated in lockstep. ## Deferred Listed for honesty so the gap is visible, not buried in commit history. - **Per-page OG images.** Attempted with `astro-og-canvas` (Satori-based); blocked by a Cloudflare-adapter incompatibility (`__dirname is not defined` inside the worker bundle when `canvaskit-wasm` loads). Picking this back up means either a static OG generator that works in Cloudflare Workers or moving the doc site off the Cloudflare adapter for the OG generation step. - **Asciinema cast on Quickstart.** Needs an actual ~90-second recording of `./scripts/compose-up.sh` going from a clean clone to a working dashboard. Record once with `asciinema rec`, upload to asciinema.org, embed via ``. Until that exists, the "What you'll have in 10 minutes" callout substitutes for the visual proof. ## How to follow updates - Star the [boringstack-xyz](https://github.com/boringstack-xyz) GitHub org to surface releases in your feed. - Check the [Image updates runbook](/runbooks/image-updates/) for how the WUD overlay can notify you when new container images are published. ## Related - [Quickstart](/quickstart/); fork-and-boot path. - [Why BoringStack](/architecture/why-boringstack/); the thesis behind the changes. - [Image updates](/runbooks/image-updates/); how to know when to pull a new image. # Infra template: overview > docker-compose for single-host deployments. Postgres + Valkey + Traefik, with optional overlays for observability, error tracking, queue dashboards, and image-update detection. import DocFileTree from "../../../components/DocFileTree"; import CommandRun from "../../../components/docs-kit/CommandRun"; import FeatureGrid from "../../../components/docs-kit/FeatureGrid"; import PageIntro from "../../../components/docs-kit/PageIntro"; import SignalGrid from "../../../components/docs-kit/SignalGrid"; `infra/compose` composes `apps/api` and `apps/ui` into one running stack. It targets your laptop and a first VPS before you need cluster machinery, while keeping observability, email, and queue tooling close to the app. ## Service inventory The base stack (always on) plus per-profile services: ## Profile model Flags compose: `WITH_OBSERVABILITY=1 WITH_GLITCHTIP=1 ./dev.sh up -d` brings up both overlays alongside the base. See [Profiles & overlays](/infra/profiles-and-overlays/). ## File layout ## Networks Two bridge networks. Services are split on purpose: In prod, Traefik joins both networks so it can route HTTPS from `frontend` to services on `backend`. The data plane (Postgres, Valkey) is never exposed to `frontend`. ## Resource budgets Every service has `deploy.resources.limits` and `reservations` driven by env vars with sane defaults sized for a 4-vCPU / 8 GB host. Adjust in `compose/.env`; see [Resource limits](/infra/resource-limits/) for sizing guidance. ## What's not in this template - Kubernetes manifests. This template is Compose-first; mixing Compose and cluster YAML in the starter would blur the deployment path BoringStack keeps intentionally clear. - Application code. The apps/api and apps/ui own that. - Cloud-provider provisioning. The [OpenTofu bootstrap](/topics/provisioning-with-tofu/) in `infra/bootstrap` handles that. ## Related - [Profiles & overlays](/infra/profiles-and-overlays/); how the `WITH_*=1` flags compose over the base. - [Resource limits](/infra/resource-limits/); the per-service budgets sized for a 4 vCPU / 8 GB host. - [Secrets](/infra/secrets/); how env values reach containers without leaking into images. - [Deployment](/topics/deployment/); the production runtime path end-to-end. - [Provisioning with OpenTofu](/topics/provisioning-with-tofu/); going from zero to a live VPS. # Profiles & overlays > How `STACK=` and `WITH_*=1` flags compose docker-compose files. The base stack stays small; everything else is opt-in. import PageIntro from "../../../components/docs-kit/PageIntro"; import CommandRun from "../../../components/docs-kit/CommandRun"; import DocCallout from "../../../components/DocCallout.tsx"; import FaqGroup from "../../../components/FaqGroup.tsx"; import FaqItem from "../../../components/FaqItem.tsx"; The infra stack defaults small: Postgres + Valkey + your apps. Everything else (observability, error tracking, queue dashboard, image-update detection, local email catcher) is opt-in via a flag. Traefik runs in the prod profile only; dev uses Vite's dev-server proxy for same-origin DX. Composition happens in `dev.sh`, which assembles the `docker-compose` invocation based on env vars. ## How a stack is assembled ```mermaid flowchart LR base["base stack
postgres · valkey
api · ui"] stack{"STACK= ?"} dev["+ development-labels
host ports for data services"] prod["+ production-labels
traefik · HTTPS · ACME · path routing"] obs["+ observability"] glitch["+ glitchtip"] bullmq["+ bullmq (dev only)"] wud["+ wud"] mailpit["+ mailpit (dev only)"] base --> stack stack -->|dev| dev stack -->|prod| prod base -.->|WITH_OBSERVABILITY=1| obs base -.->|WITH_GLITCHTIP=1| glitch base -.->|WITH_BULLMQ=1| bullmq base -.->|WITH_WUD=1| wud base -.->|WITH_MAILPIT=1| mailpit ``` The result is a single `docker compose -f ... -f ... --profile ...` command. `dev.sh` is plain bash; you can read exactly what gets merged. ## Design choices First-time setup boots fast and uses minimal RAM. No combinatorial config files; each overlay is independent. HTTPS, ACME, and security headers live in prod-only files. Bull-board has no auth and no place in production. `docker compose config` stays readable; overlays can be skipped cleanly. ## The full opt-in matrix Adds Prometheus, Grafana, Loki, Promtail, Alertmanager, and exporters. GlitchTip (Sentry-compatible error tracking); reuses base Postgres + Valkey. Bull-board UI at `bullmq.localhost`; dev only. WUD watches container images. In prod, app images (`api`, `ui`) are auto-deployed while base images remain notify-only; Discord/Slack webhooks are optional. Mailpit SMTP catcher at `:8025`; dev only. Combinations: `WITH_OBSERVABILITY=1 WITH_GLITCHTIP=1 ./scripts/compose-up.sh` is supported (and runs in CI). Do **NOT** run the `WITH_BULLMQ` overlay in a public production environment. The BullMQ dashboard has no auth; exposing it puts your queues on the public web. ## STACK=dev vs STACK=prod **dev:** bind-mounted source, hot reload. **prod:** pre-built images pulled from GHCR. **dev:** not started; Vite dev-server proxies `/api/*` directly. **prod:** started; terminates TLS, path-routes `/api/*` and `/health` to api, everything else to ui. **dev:** `http://localhost:3001`. **prod:** `https://${PUBLIC_UI_HOST}` (one domain, same-origin). **dev:** none. **prod:** Let's Encrypt ACME via Traefik. **dev:** Postgres on `:5432`, Valkey on `:6379` published to host. **prod:** internal-only, not published. `STACK=prod` adds Traefik and path-routing on top of the same data plane (Postgres + Valkey). No CORS in either profile. ## Reading what's running `./dev.sh` forwards every argument to `docker compose` with the merged file list; so any compose command works (`logs`, `exec`, `top`, etc.). ## Adding an overlay 1. Write a new `docker-compose..yml` with the additional services. 2. Add a `WITH_=1` clause in `dev.sh` mirroring the existing ones. 3. Document the flag in `compose/.env.example`. 4. Update the [Commands cheatsheet](/reference/commands/). Overlays are independent files, so you can ship one without touching the base. ## Source [`compose/dev.sh`](https://github.com/boringstack-xyz/boringstack/blob/main/infra/compose/compose/dev.sh); the orchestrator. [`compose/docker-compose.*.yml`](https://github.com/boringstack-xyz/boringstack/tree/main/infra/compose/compose); the base + overlays. ## Related - [Infra overview](/infra/overview/); service inventory. - [Resource limits](/infra/resource-limits/); sizing per service. - [Commands cheatsheet](/reference/commands/); every flag in one place. # Resource limits > Every service has CPU and memory caps driven by env vars. Defaults sized for a 4-vCPU / 8 GB host. import PageIntro from "../../../components/docs-kit/PageIntro"; import CommandRun from "../../../components/docs-kit/CommandRun"; import DataMatrix from "../../../components/docs-kit/DataMatrix"; import FaqGroup from "../../../components/FaqGroup.tsx"; import FaqItem from "../../../components/FaqItem.tsx"; Every container in the stack has `deploy.resources.limits` and `deploy.resources.reservations` set from env vars. The defaults target a 4-vCPU / 8 GB VPS, the cheapest tier on Hetzner / OVH / DigitalOcean that's usable in production. Bigger box? Bump the knobs. ## Why limits matter A misbehaving worker consuming all memory shouldn't take Postgres down with it. Limits draw the boundaries; reservations guarantee a service can boot even when the host is busy. Without limits, one runaway process kills every other service on the host. ## Design choices Failures stay isolated; the host stays responsive. Right-sizing is a config change, not a code change. Cheapest production-viable VPS tier; everything else scales up from there. Reserves the minimum to boot; allows bursts up to the limit. Avoids per-runtime tuning until measurements say otherwise. ## The shape of a knob Each service has four env vars: limits CPU/memory, reservations CPU/memory. Example for Postgres: ```bash POSTGRES_LIMITS_CPUS=1.0 POSTGRES_LIMITS_MEMORY=512M POSTGRES_RESERVATIONS_CPUS=0.25 POSTGRES_RESERVATIONS_MEMORY=128M ``` Same pattern for `VALKEY_`, `TRAEFIK_`, `API_DEV_`, `UI_DEV_`, `API_`, `UI_`, and the optional [overlays](/reference/glossary#overlay). ## Default sizing (4 vCPU / 8 GB) Sum of limits exceeds host capacity on purpose: containers don't all peak simultaneously. Reservations are sized so nothing can starve. ## Sizing for bigger hosts Postgres at default. One of each worker/API replica. `POSTGRES_LIMITS_MEMORY=2G`, `POSTGRES_LIMITS_CPUS=2.0`. Consider horizontal API replication. Postgres deserves its own host. Multiple API + worker replicas; revisit Valkey Cluster. Above the 16 vCPU mark, the single-host model itself becomes the bottleneck; that's the right time to look at the planned Kubernetes path. ## Diagnosing an OOM kill --format '{{.State.OOMKilled}}'", "docker inspect --format '{{.State.ExitCode}}'", "docker stats", ]} output={[ { tone: "warn", text: "true ← OOMKilled=true means memory limit was hit" }, { tone: "warn", text: "137 ← exit code 137 = SIGKILL by OOM" }, { tone: "info", text: "NAME CPU % MEM USAGE / LIMIT" }, ]} /> If `OOMKilled=true`, bump the matching `*_LIMITS_MEMORY` knob in `.env` and redeploy. If it keeps happening, the underlying code is leaking; fix the leak, don't keep raising the ceiling. `docker stats` shows live usage so you can see how close services run to their limits in steady state. Anything pegged >80% of its memory limit is a candidate for a bump. ## Source [`compose/.env.example`](https://github.com/boringstack-xyz/boringstack/blob/main/infra/compose/compose/.env.example); every knob, commented. [`compose/docker-compose.yml`](https://github.com/boringstack-xyz/boringstack/blob/main/infra/compose/compose/docker-compose.yml); where they're wired into `deploy.resources`. [`docs/resource-limits.md`](https://github.com/boringstack-xyz/boringstack/blob/main/infra/compose/docs/resource-limits.md); extended sizing guide. ## Related - [Profiles & overlays](/infra/profiles-and-overlays/); overlays add their own services with their own limits. - [Observability](/topics/observability/); Grafana dashboards show resource pressure over time. # Secrets > Env-file secrets with filesystem permissions. Pragmatic floor for a single-host stack; rotation playbooks; when to graduate to a secret manager. import PageIntro from "../../../components/docs-kit/PageIntro"; import CommandRun from "../../../components/docs-kit/CommandRun"; import DocCallout from "../../../components/DocCallout.tsx"; import FaqGroup from "../../../components/FaqGroup.tsx"; import FaqItem from "../../../components/FaqItem.tsx"; The infra stack puts secrets in `compose/.env` (gitignored) and passes them to containers via `env_file:` references. No Docker secrets, no Vault, no SOPS. For a single-host stack, env files + filesystem permissions work. When you outgrow that floor, see [When to graduate](#when-to-graduate) below. Until then, every step you add is complexity for a problem you don't have yet. ## How a secret flows ```mermaid flowchart LR envfile["compose/.env
chmod 600 · gitignored"] compose["docker-compose.yml
env_file: .env"] container["container
process.env.SECRET_X"] validator["env validator
(apps/api)"] envfile --> compose --> container --> validator ``` The container sees a normal environment variable. The API app's validator refuses to boot in production if anything required is missing or malformed. See [Env validator](/api/env-validator/). ## Design choices One file to back up, one file to protect; no scattered config. Filesystem permissions are the access control. Avoids leaking secrets into `docker-compose.yml`. Adding one is harder to undo than to add later. Misconfiguration shows up at boot, not at the first request. ## Bootstrapping Every secret in `.env.example` has a comment explaining what it's for and where it's required. ## What counts as a secret Rotation cadence: annually, or after a suspected leak. Signs access cookies and hashes refresh tokens. Rotate after any incident; otherwise leave alone. Per provider policy; rotate after staff turnover. When a key is leaked; per vendor rotation guidance. When the webhook endpoint is regenerated. Cosmetic config (`POSTGRES_USER`, `EMAIL_FROM`) isn't a secret; it's just config. Don't put it through the same rotation rigor. ## Rotation playbooks ### Postgres password 1. Generate a new strong password. 2. Update `POSTGRES_PASSWORD` in `compose/.env`. 3. `ALTER USER app WITH PASSWORD '...';` inside Postgres. 4. `./dev.sh restart` to recycle the app containers with the new connection string. ### JWT secret 1. Update `JWT_SECRET` (32+ chars) in `compose/.env`. 2. `./dev.sh restart api`. 3. Every access cookie and refresh session is invalidated. Users get a 401 and re-login. ### OAuth secret 1. Rotate at the provider (Google / GitHub / LinkedIn dashboard). 2. Update the matching `*_OAUTH_CLIENT_SECRET` in `compose/.env`. 3. `./dev.sh restart api`. No user-visible impact unless they're mid-OAuth at the moment. ### Provider key (Resend / Cloudflare / Stripe) 1. Provision a new key alongside the old one. 2. Update `compose/.env`, restart the API. 3. Confirm send/charge works. 4. Revoke the old key. Provision the new key before revoking the old one so nothing breaks mid-rotation. ## On a leak - **Rotate immediately:** Do not wait for the post-mortem or incident report. - **Audit the logs:** Run `SELECT * FROM audit.audit_log WHERE created_at > ''` to spot anomalous service usage. - **Force re-login:** Rotate `JWT_SECRET` immediately if session integrity is in doubt. - **Check Git history:** If `.env` was committed by mistake, run `git log -p compose/.env` and assume every secret in it is fully compromised. ## When to graduate Env files plus 600 permissions stop being adequate when one of these is true: - Multiple operators with different scopes: "Alice can read backups, Bob can read app secrets, nobody can read both" needs a secret manager. - Audit-trail requirements: SOC 2, HIPAA, and similar want a log of who read which secret when. - Automated rotation: rotation more often than humans can do reliably. Common upgrade paths: - [SOPS](https://github.com/getsops/sops) + age: encrypted env files in git. Cheapest upgrade, no infra to run. - [Hashicorp Vault](https://www.vaultproject.io): purpose-built, all the features. Adds a service to run + back up. - Cloud-provider KMS / Secrets Manager: AWS / GCP / Azure native. Easy if you're already on that cloud. ## Backups `compose/.env` is the most security-sensitive file in the repo and the most important to back up. Treat it like a private SSH key: encrypted, off-host, and recoverable without the live server. See [Backups](/runbooks/backups/) for the Postgres side; the `.env` itself is small enough to commit to a private secrets repo or stash in a password manager. ## Source [`compose/.env.example`](https://github.com/boringstack-xyz/boringstack/blob/main/infra/compose/compose/.env.example); the per-var reference with comments. ## Related - [Env validator](/api/env-validator/); the layer that refuses to boot if required secrets are missing. - [Environment variables](/reference/env-vars/); cross-repo index. # Quickstart > Clone the BoringStack monorepo, run setup.sh, sign in at localhost:3001. import CommandRun from "../../components/docs-kit/CommandRun"; import DocCallout from "../../components/DocCallout.tsx"; import DocFileTree from "../../components/DocFileTree"; import PageIntro from "../../components/docs-kit/PageIntro"; import SignalGrid from "../../components/docs-kit/SignalGrid"; import FaqGroup from "../../components/FaqGroup.tsx"; import FaqItem from "../../components/FaqItem.tsx"; Clone the [BoringStack monorepo](https://github.com/boringstack-xyz/boringstack), run `./setup.sh --up`, and Compose starts Postgres, Valkey, api-dev (migrations + OpenAPI), and ui-dev (Vite on :3001 with generated client). Optional overlays: Mailpit, Bull Board, observability, GlitchTip. - Dashboard at **`http://localhost:3001`** with register, login, and JWT cookies. - API at `http://localhost:3000/api/*`; OpenAPI at `/swagger`. - Postgres with migrations applied; Valkey for cache and BullMQ. - Generated TypeScript client in `apps/ui`; compile fails when the API contract drifts. - Overlays via env flags: Mailpit, Bull Board, observability, GlitchTip, image-update notifications. - No local Node, Bun, or Postgres installs; Compose runs every runtime. ## TL;DR You now have Postgres, Valkey, the API, and the UI running locally with hot reload. ## Prerequisites - Docker + Docker Compose v2 (`docker compose version` reports `v2.x` or newer). - About 4 GB of free RAM. - No local Node or Bun needed for the default path; everything runs in containers. ## Step 1. Clone the monorepo ```bash git clone https://github.com/boringstack-xyz/boringstack.git cd boringstack ``` Fork the monorepo on GitHub if you want your own copy under your org. API, UI, docs, Compose, and bootstrap infrastructure all live in this one tree. `./setup.sh` bootstraps `infra/compose/compose/.env`, generates a `GLITCHTIP_SECRET_KEY`, chmods scripts, and with `--up` runs `./dev.sh up -d --build`. ## Step 2. Boot the local stack Or manually: ```bash cd infra/compose/compose cp .env.example .env chmod +x dev.sh ../scripts/*.sh ./dev.sh up -d --build ``` First boot pulls base images, builds the api/ui dev images, and runs migrations. If you set `SUPERUSER_EMAIL` + `SUPERUSER_PASSWORD` in `compose/.env`, an admin user is also created. About 3 minutes on a fast laptop. ## Step 3. Sign in Open **http://localhost:3001**. If you set `SUPERUSER_EMAIL` + `SUPERUSER_PASSWORD` in `compose/.env` before booting, sign in with those credentials. Otherwise hit "Sign up" on the form and register a new account; that user is the first user in the system. You land on the dashboard, which is minimal on purpose. Start building from here. ## What's running App database; schemas `auth`, `billing`, `audit`, `app`, `notifications`. Cache and BullMQ queues. `db:push` and optional superuser seed, then exits. Bun + Elysia API with hot reload via bind mount. Vite dev server; proxies `/api/*` to `api-dev`. Traefik runs only in the prod profile. In prod it terminates TLS and path-routes `/api/*` + `/health` to the api container, everything else to ui, on one domain. ## Common next steps - Regenerate cross-app contracts: `bun run regen` from repo root (api must be on :3000 for OpenAPI). - Observability: `WITH_OBSERVABILITY=1 ./scripts/compose-up.sh` from `infra/compose/compose`. [Details](/topics/observability/). - Self-hosted error tracking: `WITH_GLITCHTIP=1 ./scripts/compose-up.sh`. [Details](/topics/error-tracking/). - Local email testing: `WITH_MAILPIT=1 ./scripts/compose-up.sh`. [Details](/topics/email-in-dev/). - Production deploy: [Deployment](/topics/deployment/) when you are ready for GHCR images, TLS, firewall, and backups. ## Stop the stack From `infra/compose/compose`: `./dev.sh down`. Volumes stay on disk. `./scripts/compose-down-clean.sh` from `infra/compose`. Prompts before delete; `CONFIRM=yes` to skip the prompt. ## Related - [Why BoringStack](/architecture/why-boringstack/). What ships in the box. - [Repository layout](/architecture/monorepo-layout/). Monorepo folders and contracts. - [Environment variables](/reference/env-vars/). Every var, what it does. - [Deployment](/topics/deployment/). Production runtime path. - [Provisioning with OpenTofu](/topics/provisioning-with-tofu/). `tofu apply` to a live VPS. # Recipe: Add a background job > Add a new BullMQ queue and worker to the apps/api. Goal, Prereqs, Steps, Verify, and what changes in code. import { Steps } from "@astrojs/starlight/components"; import DocCallout from "../../../components/DocCallout.tsx"; import HowToSchema from "../../../components/HowToSchema.astro"; import PageIntro from "../../../components/docs-kit/PageIntro"; import CommandRun from "../../../components/docs-kit/CommandRun"; import DocFileTree from "../../../components/DocFileTree"; / by copying the file pattern from src/queues/email-delivery/.", "Define the job payload type in .types.ts.", "Set the queue name and BullMQ options in .constants.ts.", "Add the producer that wraps queue.add() in .queue.ts.", "Write the idempotent worker handler in .worker.ts.", "Wire the queue and worker into src/config/setup-queues.ts and the QueueManager.", ]} /> Add a BullMQ queue and worker for background work (e.g., sending webhooks on account upgrades). Jobs survive API restarts and show up in Bull Board during dev. ## Goal The API app's `QueueManager` already owns the queue + worker lifecycle. This recipe shows how to add a new queue, end to end. ## Prereqs - A working local stack. - Read [Queues](/api/queues/) once for the file shape. ## Steps There's no `new:queue` scaffolder. Copy the file pattern from an existing queue instead. Use [`src/queues/email-delivery/`](https://github.com/boringstack-xyz/boringstack/tree/main/apps/api/src/queues/email-delivery) as the reference shape. Every queue directory carries six files prefixed with the queue name: The [`@boring-stack-pkg/eslint-plugin-bullmq`](https://www.npmjs.com/package/@boring-stack-pkg/eslint-plugin-bullmq) plugin enforces this shape. Files outside the pattern fail the merge gate. 1. Create the directory and copy the file skeleton from `email-delivery/`: ```bash cd apps/api && mkdir -p src/queues/webhook-fanout cp src/queues/email-delivery/email-delivery.*.ts \ src/queues/webhook-fanout/ # then rename the copied files from email-delivery.* to webhook-fanout.* ``` 2. Define the job payload type in `webhook-fanout.types.ts`: ```ts export interface IWebhookFanoutJob { accountId: string; event: "account.upgraded" | "account.cancelled"; targetUrl: string; payload: Record; } ``` 3. Set the queue name and options in `webhook-fanout.constants.ts`: ```ts export const WEBHOOK_FANOUT_QUEUE = "webhook-fanout" as const; export const WEBHOOK_FANOUT_DEFAULT_OPTS = { attempts: 5, backoff: { type: "exponential", delay: 1_000 }, removeOnComplete: 1_000, removeOnFail: 5_000, } as const; ``` 4. Add the producer in `webhook-fanout.queue.ts`: ```ts export async function enqueueWebhookFanout( queue: Queue, job: IWebhookFanoutJob, ) { return queue.add(job.event, job, WEBHOOK_FANOUT_DEFAULT_OPTS); } ``` 5. Write the worker in `webhook-fanout.worker.ts`: ```ts export async function webhookFanoutWorker(job: Job) { const res = await fetch(job.data.targetUrl, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(job.data.payload), }); if (!res.ok) throw new Error(`webhook responded ${res.status}`); return { delivered: true }; } ``` Throwing makes BullMQ retry with the configured backoff. Make sure your handler is idempotent. The same job can fire multiple times. 6. Wire the queue and worker into `webhook-fanout.setup.ts`, then register the setup function in `src/config/setup-queues.ts` alongside the existing queues (`email-delivery`, `notification-dispatch`, etc.). 7. Producer code calls it via the `QueueManager`: ```ts await queueManager.enqueueWebhookFanout({ accountId, event: "account.upgraded", targetUrl: "https://hooks.example.com/upgrades", payload: { plan: "pro" }, }); ``` When `QUEUES_ENABLED=false` (default in tests), the manager runs the worker inline so test paths don't need a real Valkey. ## Verify - Boot the optional Bull Board overlay to watch jobs: - Visit `http://bull-board.localhost` (or the dev URL printed in the API logs) to see the queue, active jobs, and retry counts. - Trigger the producer (test with `bun test` or a curl to a route that enqueues). The job appears in Bull Board's "completed" or "failed" tab. - The audit log captures lifecycle events if you wrap the worker in the [audit helper](/api/audit-log/). ## What changes in code - `apps/api/src/queues/webhook-fanout/`: new directory, 6 files, lint-enforced shape (constants / types / queue / worker / setup / index). - `apps/api/src/config/setup-queues.ts`: call your new setup function alongside the existing queues. - `apps/api/src/queues/queue-manager.ts`: add the `enqueueWebhookFanout(...)` method that wraps the producer for callers. - Wherever your producer lives (a service file): call `queueManager.enqueueWebhookFanout(...)`. No new dependencies. The pattern is reused for every kind of background work; the email pipeline, notifications, and audit log already follow it. Long-running workers stay attached. Don't try to "trigger and exit." BullMQ needs the worker process up to claim jobs. In production this is the same apps/api container; in scale-out you can split workers into their own container that runs `bun run start:worker`. ## Related - [Queues](/api/queues/); the BullMQ + QueueManager spine in depth. - [Background work](/architecture/background-work/); the architecture story. - [Audit log](/api/audit-log/); recording lifecycle events fire-and-forget. - [Lint as the contract](/architecture/lint-as-contract/); the BullMQ plugin that enforces this shape. # Recipe: Add S3-compatible uploads > Direct-to-bucket browser uploads via presigned URLs. Works with Cloudflare R2, AWS S3, Backblaze B2, MinIO. Goal, Prereqs, Steps, Verify, and what changes in code. import { Steps } from "@astrojs/starlight/components"; import DocCallout from "../../../components/DocCallout.tsx"; import HowToSchema from "../../../components/HowToSchema.astro"; import PageIntro from "../../../components/docs-kit/PageIntro"; import CommandRun from "../../../components/docs-kit/CommandRun"; import DocFileTree from "../../../components/DocFileTree"; Add direct-to-bucket file uploads from the browser with S3 presigned URLs. File bytes stay off your API servers. ## Goal Let an authenticated user upload a file straight from the browser into an S3-compatible bucket. The bytes never touch the API host; the API only signs a short-lived PUT URL and records the resulting object in Postgres. ## Prereqs - A working local stack. - A bucket with S3-compatible credentials. Recommended: [Cloudflare R2](https://developers.cloudflare.com/r2/), which has zero egress fees and the same auth model as S3. AWS S3, Backblaze B2, and a local MinIO container also work. - The bucket configured with a CORS rule that allows your origin to PUT. ## Steps 1. Add bucket credentials to `compose/.env`: ```bash S3_ENDPOINT=https://.r2.cloudflarestorage.com # or AWS region URL S3_REGION=auto # or us-east-1 etc. S3_BUCKET=your-bucket-name S3_ACCESS_KEY_ID=... S3_SECRET_ACCESS_KEY=... S3_PUBLIC_BASE_URL=https://files.example.com # CDN-fronted public URL, optional ``` Add them to `src/config/env.schema.ts` so the [env validator](/api/env-validator/) refuses to boot if they're missing in prod. 2. Add the AWS SDK to the API: ```bash cd apps/api && bun add @aws-sdk/client-s3 @aws-sdk/s3-request-presigner ``` 3. Create a new feature folder. Use the scaffold so the layer split is correct from the start: ```bash cd apps/api && bun run new:resource uploads ``` That writes `uploads.routes.ts`, `uploads.service.ts`, `uploads.types.ts` and wires the routes into `config/routes.ts`. 4. In `uploads.service.ts`, sign a PUT URL keyed by `accountId + uuid`. Return `{ uploadUrl, objectKey, publicUrl }`. Use `@aws-sdk/s3-request-presigner` `getSignedUrl(...)` with a 5-minute TTL. 5. Add a `media` table to the schema. Minimum columns: `id`, `account_id`, `object_key`, `mime_type`, `size_bytes`, `created_at`. After a successful PUT, the UI POSTs a `finalize` request to the API which inserts the row inside a transaction. 6. UI side. The UI app's `lib/api` client already has the typed routes (regenerate with `bun run generate:api`). A simple file picker: ```ts const { uploadUrl, objectKey, publicUrl } = await apiClient.POST("/api/uploads/sign", { body: { mime, size } }); await fetch(uploadUrl, { method: "PUT", body: file }); await apiClient.POST("/api/uploads/finalize", { body: { objectKey } }); ``` ## Verify - Browser DevTools network tab: the PUT goes straight to your bucket origin, not to your API host. - The bucket dashboard shows the new object under `/` (or whatever key pattern you chose). - A row appears in the `media` table: ```sql SELECT id, account_id, object_key, mime_type FROM media ORDER BY created_at DESC LIMIT 5; ``` - The audit log shows `media.upload_finalized`. ## What changes in code - `apps/api/src/config/env.schema.ts`: add the six S3 env vars to the [TypeBox env shape](/api/env-validator/). - `apps/api/src/api/uploads/`: new feature folder, follows the [route/service/types split](/api/overview/). - `apps/api/src/clients/postgres/schema/media.ts`: new Drizzle table. - `apps/api/drizzle/0xxx_add_media_table.sql`: generated migration (`bun run db:generate`). - `apps/ui/src/features//`: picker + progress UI calling the typed client. Make `S3_PUBLIC_BASE_URL` an authenticated reader (signed-URL GET) for anything sensitive. R2's "public bucket" toggle is convenient but is permanent until you flip it off. Easy to leak with. ## Related - [Env validator](/api/env-validator/); how to declare the bucket vars cleanly. - [API overview](/api/overview/); the per-feature folder shape this recipe follows. - [Multi-tenant model](/api/multi-tenant/); scoping uploads by `accountId`. - [Audit log](/api/audit-log/); recording upload lifecycle events. # Recipe: Add a service to Compose > Add a new container to the BoringStack Compose stack as an opt-in overlay with profiles, resource limits, and (optionally) Traefik routing. Goal, Prereqs, Steps, Verify, and what changes in code. import { Steps } from "@astrojs/starlight/components"; import DocCallout from "../../../components/DocCallout.tsx"; import HowToSchema from "../../../components/HowToSchema.astro"; import PageIntro from "../../../components/docs-kit/PageIntro"; import CommandRun from "../../../components/docs-kit/CommandRun"; .yml under compose/ with the service definition and resource limits.", "Wire the overlay into compose/dev.sh as a WITH_=1 flag.", "Add a dev-only Traefik labels overlay for a friendly hostname.", "Add a parallel prod-labels overlay for HTTPS and BasicAuth in production.", "Document the new flag and any required env vars in compose/.env.example.", "Boot with WITH_=1 ./scripts/compose-up.sh and verify health.", ]} /> Add a containerized service to your local stack as an isolated overlay. The core stack stays light; opt-in tools get their own resource budgets. ## Goal Add a new container to the stack as an [overlay](/reference/glossary#overlay). A `WITH_=1` flag turns it on without changing the base file. Same model the observability, GlitchTip, Bull Board, and Mailpit overlays use. Worked example below: adding **Meilisearch** as an opt-in search engine, exposed on a dev hostname and behind Basic Auth in prod. ## Prereqs - A working local stack. - Read [Profiles & overlays](/infra/profiles-and-overlays/) for the convention `./dev.sh` and `compose-up.sh` follow. ## Steps 1. Create the overlay file. In `infra/compose/compose/`: ```yaml # docker-compose.meilisearch.yml services: meilisearch: image: getmeili/meilisearch:v1.10 restart: unless-stopped environment: MEILI_MASTER_KEY: ${MEILI_MASTER_KEY:?MEILI_MASTER_KEY is required} MEILI_NO_ANALYTICS: "true" volumes: - meilisearch_data:/meili_data networks: - backend deploy: resources: limits: cpus: "${MEILI_LIMITS_CPUS:-0.5}" memory: ${MEILI_LIMITS_MEMORY:-512M} reservations: cpus: "${MEILI_RESERVATIONS_CPUS:-0.1}" memory: ${MEILI_RESERVATIONS_MEMORY:-128M} volumes: meilisearch_data: ``` The env-var-with-default pattern matches the rest of the stack (see [Resource limits](/infra/resource-limits/)). 2. Wire the overlay into `dev.sh`. The orchestrator already understands `WITH_=1` flags. Add the file to the `case` block: ```bash # in compose/dev.sh if [ "${WITH_MEILISEARCH:-0}" = "1" ]; then COMPOSE_FILES+=( "-f" "compose/docker-compose.meilisearch.yml" ) fi ``` 3. Add a dev-only labels overlay if you want a friendly hostname. Mirror the pattern in `docker-compose.development-labels.yml`: ```yaml # docker-compose.meilisearch-dev-labels.yml services: meilisearch: labels: traefik.enable: "true" traefik.http.routers.meilisearch.rule: Host(`meilisearch.localhost`) traefik.http.services.meilisearch.loadbalancer.server.port: "7700" ``` Add a parallel `meilisearch-prod-labels.yml` for HTTPS + Basic Auth in production, following the GlitchTip prod overlay as the reference shape. 4. Document the new flag in `compose/.env.example`: ```bash # Meilisearch overlay (WITH_MEILISEARCH=1) MEILI_MASTER_KEY= ``` 5. Boot it: ```bash WITH_MEILISEARCH=1 ./scripts/compose-up.sh ``` ## Verify - `docker compose ps` shows the `meilisearch` container in the running list alongside the base stack. - The volume `meilisearch_data` exists: `docker volume ls | grep meilisearch`. - The dev URL responds: `curl http://meilisearch.localhost/health`. - Flags compose: `WITH_MEILISEARCH=1 WITH_OBSERVABILITY=1 ./scripts/compose-up.sh` brings up both overlays cleanly. - Stopping cleanly preserves data: `./scripts/compose-down.sh` then re-up; the index is still there. ## What changes in code - `infra/compose/compose/docker-compose.meilisearch.yml`: new (the service). - `infra/compose/compose/docker-compose.meilisearch-dev-labels.yml`: new (Traefik routing in dev). - `infra/compose/compose/docker-compose.meilisearch-prod-labels.yml`: new (HTTPS + BasicAuth in prod). - `infra/compose/compose/dev.sh`: one new `if` block for the `WITH_MEILISEARCH=1` flag. - `infra/compose/compose/.env.example`: new env stanza. No changes to `docker-compose.yml`. That's the point of the overlay model: the base never grows, opt-in services live in their own files. Every overlay should declare `deploy.resources.limits`. A misbehaving overlay without limits can starve the base stack on a small VPS. The pattern is in every existing overlay file. Copy and adjust. ## Related - [Profiles & overlays](/infra/profiles-and-overlays/); how flags compose over the base stack. - [Resource limits](/infra/resource-limits/); the per-service budget convention. - [Secrets](/infra/secrets/); how env values reach containers safely. - [Infra template overview](/infra/overview/); the source map for compose files. # Recipe: Add Stripe Checkout > Wire Stripe Checkout, the Customer Portal, and webhooks onto the API app's existing billing spine. Goal, prereqs, steps, verification, and code touchpoints. import { Steps } from "@astrojs/starlight/components"; import DocCallout from "../../../components/DocCallout.tsx"; import HowToSchema from "../../../components/HowToSchema.astro"; import PageIntro from "../../../components/docs-kit/PageIntro"; import CommandRun from "../../../components/docs-kit/CommandRun"; Wire Stripe Checkout, the Customer Portal, and webhooks onto your local stack for subscriptions, billing updates, and feature gates. ## Goal Finish with a paid plan a user can subscribe to via Stripe Checkout, manage from the Customer Portal, and sync back into your DB through webhooks. The apps/api already ships [`api/billing/`](https://github.com/boringstack-xyz/boringstack/tree/main/apps/api/src/api/billing) with the right shape. This recipe wires it to a real Stripe account. ## Prereqs - A working local stack (`./scripts/compose-up.sh` clean). - A [Stripe account](https://dashboard.stripe.com). Test Mode is fine; no business verification needed for this recipe. - One product + recurring price already created in the Stripe dashboard. - The [Stripe CLI](https://docs.stripe.com/stripe-cli) installed locally for webhook forwarding. ## Steps 1. Flip the feature flag and capture the Stripe secrets in `compose/.env`: ```bash BILLING_ENABLED=true STRIPE_SECRET_KEY=sk_test_... STRIPE_WEBHOOK_SECRET=whsec_... # from `stripe listen` (next step) STRIPE_PRICE_ID_FREE=price_... # your free-tier price STRIPE_PRICE_ID_PRO=price_... # your paid-tier price ``` `BILLING_ENABLED=false` is the default. The billing routes don't mount until you flip it. The [env validator](/api/env-validator/) refuses to boot the API in production if any required Stripe key is missing while `BILLING_ENABLED=true`. 2. Forward webhooks to your local API: ```bash stripe listen --forward-to localhost:3000/api/v1/billing/stripe/webhooks ``` Copy the `whsec_...` signing secret printed on first connect into `STRIPE_WEBHOOK_SECRET`. Then restart: ```bash ./scripts/compose-up.sh # picks up new env ``` 3. Extend the UI billing feature. `apps/ui/src/features/billing/` already ships the page, queries, and mutations for the starter billing flow. Add or adapt the Checkout call site following the [component anatomy](/ui/architecture-rules/). Inside `Billing.mutations.ts`, POST to the real endpoint via the typed client: ```ts // Billing.mutations.ts const startCheckout = (planId: string) => apiClient.POST("/api/v1/billing/stripe/checkout-session", { body: { planId, successUrl: `${window.location.origin}/billing/success`, cancelUrl: `${window.location.origin}/billing`, }, }); ``` The endpoint returns a Stripe Checkout URL; redirect the browser to it. 4. Surface the Customer Portal. Add a "Manage subscription" mutation that POSTs to `/api/v1/billing/stripe/portal-session` and redirects the user to Stripe's hosted portal for cancellation and payment-method updates. 5. Add your plan to the [ACL feature resolver](/api/acl/). The `accountId → activePlan → enabledFeatures` chain is the source of truth. Adding a `pro` plan with a feature flag is one entry in the plan config. ## Verify - Click **Upgrade** as a logged-in user. The browser redirects to Stripe Checkout. Use Stripe's test card `4242 4242 4242 4242`, any future date, any CVC. - After payment, the redirect drops you back at the configured `success_url`. The `stripe listen` terminal shows `checkout.session.completed` and `customer.subscription.created` events arriving. - In the API's `audit.audit_log` table you should see entries for the subscription lifecycle (the billing service writes them fire-and-forget). Quick check: ```sql SELECT actor, event, payload->>'plan' AS plan FROM audit.audit_log WHERE event LIKE 'billing.%' ORDER BY ts DESC LIMIT 5; ``` - The ACL endpoint `/api/me/features` returns the upgraded plan's features. ## What changes in code - `apps/api/src/api/billing/billing.service.ts`: fill in plan-to-price mapping and the webhook handler logic for `checkout.session.completed`. - `apps/api/src/api/billing/billing.routes.ts`: already exposes `/stripe/checkout-session`, `/stripe/portal-session`, `/stripe/webhooks` under the billing router; no new routes needed. - ACL plan config: declare your plan + features so the [ACL resolver](/api/acl/) knows about it. - `apps/ui/src/features/billing/`: existing feature folder hosting the mutations that call the typed client and the page with the Upgrade button and Customer Portal link. No new dependencies. No new patterns. Stripe is wired as a [pluggable provider](/api/env-validator/) so changing pricing later is a config edit, not a code rewrite. Stripe retries webhooks aggressively. The billing service uses an idempotency table keyed on Stripe's `event.id`. Don't disable it. The [`@boring-stack-pkg/eslint-plugin-stripe-webhooks`](https://www.npmjs.com/package/@boring-stack-pkg/eslint-plugin-stripe-webhooks) plugin enforces the pattern. ## Related - [Billing](/api/billing/); the existing billing spine and what it ships. - [ACL & feature resolution](/api/acl/); plan + feature gating after subscription. - [Audit log](/api/audit-log/); where subscription events get recorded. - [Decision log](/architecture/decisions/); why the architecture-as-lint discipline matters for Stripe webhooks. # Commands cheatsheet > Every command you'll run during a normal day of working on BoringStack. import PageIntro from "../../../components/docs-kit/PageIntro"; import CommandRun from "../../../components/docs-kit/CommandRun"; import FaqGroup from "../../../components/FaqGroup.tsx"; import FaqItem from "../../../components/FaqItem.tsx"; CLI commands for day-to-day development, testing, and operations. For which script file each command runs, see [Scripts & tooling](/reference/scripts-tooling/). ## Monorepo root Run from the repository root (`boringstack/`): `bun run regen` — ACL types, OpenAPI schema (needs api on :3000), lint-meta RULES.md, docs JSON catalogs. `bun run check` — same gates as CI for contracts across apps/docs. `bun run check:full` — check plus `apps/api` validate, `apps/ui` validate, and `apps/docs` `build:ci`. `./setup.sh` or `./setup.sh --up` — seed compose `.env` and optionally boot the dev stack. Paths in the sections below are relative to each app unless noted. Compose commands run from `infra/compose/compose`; operational helper scripts live in `infra/compose/scripts`. ## Infra (docker-compose stack) `./scripts/compose-up.sh`. Data: n/a. `./scripts/compose-down.sh`. Data preserved. `./scripts/compose-down-clean.sh`. Data not preserved (prompts; `CONFIRM=yes` to skip confirmation). `STACK=prod ./scripts/compose-up.sh`. Data: n/a. `WITH_OBSERVABILITY=1 ./scripts/compose-up.sh`. Data: n/a. `WITH_GLITCHTIP=1 ./scripts/compose-up.sh`. Data: n/a. `WITH_BULLMQ=1 ./scripts/compose-up.sh`. Data: n/a. `WITH_WUD=1 ./scripts/compose-up.sh`. Data: n/a. `WITH_MAILPIT=1 ./scripts/compose-up.sh` (dev only). Data: n/a. `docker compose -f compose/docker-compose.yml -f compose/docker-compose.development-labels.yml --profile dev ps`. Data: n/a. `docker compose ... logs -f api-dev`. Data: n/a. `docker stats`. Data: n/a. `BACKUP_DRY_RUN=1 ./scripts/backup-wrapper.example.sh` then drop the flag. Data preserved. Flags compose: `WITH_OBSERVABILITY=1 WITH_GLITCHTIP=1 ./scripts/compose-up.sh`. ## API app `bun run dev` `bun run build` `bun run start` `bun run check` `bun run lint` `bun run lint:fix` `bun run knip` `bun run test` `bun run validate` `bun run pre-push` `bun run db:generate` `bun run db:migrate` `bun run db:push --force` `bun run db:seed` `bun run db:studio` `bun run new:resource ` `bun run build:templates` `bun run build:templates:watch` ## UI app `bun run dev` `bun run build` `bun run preview` `bun run typecheck` `bun run check` `bun run lint:fix` `bun run knip` `bun run test` `bun run test:ci` `bun run e2e` `bun run e2e:visual:update` `bun run size:check` `bun run analyze` `bun run lighthouse` `bun run validate` `bun run pre-push` `bun run generate:api` `bun run new:component ` `bun run new:feature ` `bun run storybook` ## Inside the dev containers (rare) ## Related - [Quickstart](/quickstart/); the boot-from-clone path that produces this state. - [Environment variables](/reference/env-vars/); every knob you can flip alongside these commands. - [Profiles & overlays](/infra/profiles-and-overlays/); what the `WITH_*=1` flags actually toggle. - [Glossary](/reference/glossary/); the vocabulary (stack, overlay, profile, merge gate) the commands speak. - [Deployment](/topics/deployment/); the production sequence that wraps these locally-run commands. # Cost methodology > How the cost figures on the Why BoringStack page were derived: assumptions, rate cards, and the math behind each column. import PageIntro from "../../../components/docs-kit/PageIntro"; import DocCallout from "../../../components/DocCallout.tsx"; The [cost calculator](/architecture/why-boringstack/#cost-calc-title) on the Why BoringStack page compares three solution categories at four usage stages. Prices reflect public list rate cards as of **2026-05**. Numbers are rounded up on purpose so the columns sit on the same scale, not to predict your bill to the cent. The "Managed-PaaS bundle" and "Hosted SaaS platform" columns do **not** represent a single named product. Each is a *shape* of solution priced by picking representative rate cards from that shape. Your actual vendor may be cheaper or pricier depending on which features you use. ## The four stages | Stage | MAU | Requests / day | Egress / mo | DB storage | | -------------- | -------- | -------------- | ----------- | ---------- | | Side project | ~200 | 5k | 5 GB | 1 GB | | Early startup | ~2k | 80k | 50 GB | 5 GB | | Growth | ~20k | 1M | 500 GB | 50 GB | | Scale | ~100k | 10M | 5 TB | 250 GB | The MAU number is the dial that matters most; everything else derives from typical SaaS request and storage patterns at that audience size. ## Column 1: BoringStack (VPS + Cloudflare + B2) The runtime is the BoringStack default: one Hetzner Cloud VPS running the full Compose stack (Postgres, Valkey, API, UI, Traefik), Cloudflare proxying the apex domain (free plan), and Backblaze B2 for daily Postgres backups. | Stage | Host | Monthly cost | | -------- | ------------- | ---------------------- | | Side | CX22 (€4.59) | ~$5 (€4.59 + B2 ~$0.10) | | Early | CX32 (€7.16) | ~$8 | | Growth | CX42 (€16.40) | ~$16 | | Scale | CX52 (€32.95) | ~$35 | **What's included.** Hetzner Cloud's plans include 20 TB of outbound traffic per month at every tier, so egress is effectively free under any of these scenarios. Cloudflare's free plan covers DNS, the proxy, basic WAF, and unlimited CDN bandwidth. B2 charges $0.006/GB-month for storage and gives 3× the storage as free egress, so backup egress almost never bills. **What's not included.** Application code, your own time, error tracking (hosted Sentry would add ~$26/mo above the free tier; self-hosted GlitchTip is $0 extra container cost). Cloudflare paid tier becomes worth it at the Scale stage if you want bot rules or advanced WAF rules. **Sources.** [Hetzner Cloud pricing](https://www.hetzner.com/cloud), [Cloudflare plans](https://www.cloudflare.com/plans/), [Backblaze B2 pricing](https://www.backblaze.com/cloud-storage/pricing). ## Column 2: Managed-PaaS bundle A typical "edge functions + managed Postgres" combination. The numbers come from adding a representative Pro-tier edge platform (~$20/mo base + bandwidth) to a representative managed-Postgres tier (~$25/mo base + DB storage). | Stage | Components | Monthly cost | | -------- | --------------------------------------------------------- | ------------ | | Side | Base plans, well under free-tier overages | ~$45 | | Early | Base plans, small bandwidth overage | ~$80 | | Growth | Base plans + bandwidth at 500 GB tier + DB at 50 GB tier | ~$250 | | Scale | Enterprise-style pricing for 5 TB egress + 250 GB DB | ~$1,200 | **Where the money goes.** Bandwidth and function execution time scale linearly with traffic; managed-Postgres tier-jumps happen at storage thresholds. The Scale-tier figure assumes you're on the dedicated/enterprise pricing rung most platforms push you to past ~1M requests/day, not a self-serve plan. **What's included.** Auto-scaling compute, automatic TLS, CDN, managed backups, web dashboards. Things you'd otherwise wire yourself. **What's not included.** Auth-as-a-service, email, error tracking, background-job platforms. Each is priced separately if you adopt them. **Why this column exists.** It represents the "fast to deploy, slow to control" path: trivial onboarding, but the bill scales with usage and you cannot move the runtime off the platform without a rewrite. ## Column 3: Hosted SaaS platform The all-in-one shape: auth + DB + background work + email + analytics bundled into one platform, priced per seat or per active user. | Stage | Pricing model | Monthly cost | | -------- | --------------------------------------------------- | ------------ | | Side | Free tier with a few paid add-ons | ~$25 | | Early | $0.10–$0.20 / MAU above free tier | ~$200 | | Growth | $0.07 / MAU at 20k MAU after volume discount | ~$1,500 | | Scale | Enterprise contract, $0.06 / MAU + platform license | ~$6,000 | **Where the money goes.** Per-MAU pricing is brutal at scale: what costs $25/mo for 200 users costs $6,000/mo for 100k. The platform shape is great for fast product-market-fit experiments but stops making sense once you have real traction. **What's included.** Almost everything an early team would have to wire: auth, DB, queues, email templates, file storage, an admin dashboard. **What's not included.** Custom code paths the platform doesn't support; you write integrations or live without them. Migrating off is usually a full rebuild. **Why this column exists.** It represents the "buy the spine, pay forever" path. Lowest day-1 friction, highest steady-state cost, hardest to leave. ## What this comparison does not measure - **Engineering time.** The PaaS and SaaS columns save days of setup. The BoringStack column trades some of that day-1 time for long-term ownership. Calculator-row cost is *infra only*. - **Reliability differences.** Multi-AZ managed Postgres has stronger guarantees out of the box than one VPS. The Scale-tier BoringStack figure assumes you've added a hot standby or moved to managed Postgres voluntarily. See the [Decision Log](/architecture/decisions/) on Postgres. - **Enterprise sales pricing.** Every column shows public list price. Real enterprise deals are negotiated and can shift any column by 30–60%. - **Free-tier edge cases.** New-account credits, promotional pricing, and pre-revenue waivers can make the early stages cheaper than shown for all three columns. ## How to update these numbers Edit `src/components/landing/costData.ts`. That's the single source of truth for the calculator and these tables. Update the `pricingAsOf` constant at the same time so the published-date footer stays honest. ## Related - [Why BoringStack](/architecture/why-boringstack/); the calculator lives there. - [Decision Log](/architecture/decisions/); the per-choice reasoning behind every "boring" pick that affects this bill. - [Deployment](/topics/deployment/); how to actually run on the BoringStack column. - [Provisioning with OpenTofu](/topics/provisioning-with-tofu/); zero-to-VPS at the Hetzner prices above. # Environment variables > One place to look up "what does FOO_BAR do, and where does it live?" across apps/api, apps/ui, and the infra stack. import PageIntro from "../../../components/docs-kit/PageIntro"; import SignalGrid from "../../../components/docs-kit/SignalGrid"; import DocCallout from "../../../components/DocCallout.tsx"; import FaqGroup from "../../../components/FaqGroup.tsx"; import FaqItem from "../../../components/FaqItem.tsx"; Each workspace has its own `.env.example` for the vars it consumes. This page maps which app or infra layer owns a variable, why it exists, and which `.env.example` has the full reference. ## Which file to edit For Docker Compose-driven setups (the default), **`compose/.env` is the single file you edit.** It overrides defaults baked into `docker-compose.yml` and gets injected into the right containers via `env_file:` references. ## Categories ### Process + observability **Repo:** api. **Required:** yes. Values `development`, `production`, or `test`; gates prod-only invariants. **Repo:** api. **Required:** no. Default 3000. **Repo:** api. **Required:** no (default `API Template`). Public-facing identity used in email From-names, OAuth consent metadata, and the OpenAPI document title. **Repo:** api. **Required:** no. One of `debug`, `info`, `warn`, `error`. **Repo:** api. **Required:** no. Empty means the SDK is a no-op. **Repo:** api. **Required:** no. Range 0 to 1, default 0.1. **Repo:** ui. **Required:** no. Same wire protocol as Sentry or GlitchTip. ### Database + cache **Repo:** api. **Required:** yes. Postgres connection string. **Repo:** api. **Required:** no. Default `true`. Keep it true in production so Postgres TLS is verified instead of merely encrypted. **Repo:** api. **Required:** no. Optional PEM CA bundle for production Postgres providers that use a private CA. **Repo:** api. **Required:** yes when queues or cache use Valkey. Defaults `localhost` and 6379. **Repo:** api. **Required:** in production when queues, Valkey cache, notification SSE, or OAuth is on. **Repo:** api. **Required:** no. Default 0. **Repo:** api. **Required:** no. `false` uses noop; providers include `memory` and `valkey`. **Repo:** api. **Required:** no. When `false`, dispatch falls back to inline execution. **Repo:** api. **Required:** no. Enables the live notification stream. `true` requires Valkey because API instances coordinate through pub/sub. ### Auth + session **Repo:** api. **Required:** yes (32+ chars). Signs the 15-minute access cookie and hashes refresh tokens. Rotation forces every user to sign in again. **Repo:** api. **Required:** yes. App origin used for OAuth success redirects and Stripe return-url allowlisting. **Repo:** api. **Required:** yes. Public URL used to build OAuth callback URLs. Same-origin default is the frontend origin; cross-origin setups use the API origin. **Repo:** api. **Required:** no (default empty). CSV. Empty means same-origin deployment (default); CORS is not mounted. When set, every origin must be HTTPS with no wildcards. **Repo:** api. **Required:** no (default empty). Optional first-boot superuser. Both set means `db:seed` creates an `admin` user on first run. Empty means no-op; register via the UI instead. **Repo:** api. **Required:** when Google OAuth is enabled. **Repo:** api. **Required:** when GitHub OAuth is enabled. **Repo:** api. **Required:** when LinkedIn OAuth is enabled. **Repo:** api. **Required:** no (defaults 100 / 60000ms). Per-IP global rate limit; defence-in-depth alongside Traefik's edge limit. **Repo:** api. **Required:** no (defaults 10 / 60000ms). Stricter per-IP limit applied to every `/api/v1/auth/*` route. Tightens the global default for the credential-stuffing surface. **Repo:** api. **Required:** no (default `false` in apps/api `.env.example`). When `true`, exposes `/api/v1/auth/__test/*` helpers outside `NODE_ENV=test`. Docker Compose dev defaults this to `true` via `API_DEV_E2E_TEST_ENDPOINTS_ENABLED` for Playwright — keep `false` in prod. ### Email **Repo:** api. **Required:** always set. One of `cloudflare` (default), `resend`, `sendgrid`, or `smtp`. **Repo:** api. **Required:** always. Sender on a verified domain. **Repo:** api. **Required:** when provider is cloudflare. **Repo:** api. **Required:** when provider is cloudflare. Scope `Email Sending: Edit`. **Repo:** api. **Required:** when provider is resend. **Repo:** api. **Required:** when provider is sendgrid. **Repo:** api. **Required:** when provider is smtp. Use `mailpit` against the dev overlay; any RFC 5321 server in prod. **Repo:** api. **Required:** when provider is smtp. Default 25; Mailpit listens on 1025. **Repo:** api. **Required:** optional. Auth when the server requires it. **Repo:** api. **Required:** no. Optional http(s) link rendered in notification email footers. ### Multi-tenant **Repo:** api. **Required:** no (defaults `false`). When `true`, the first verified signup with a non-public email domain claims that domain on its personal account; subsequent signups from the same domain hit `DOMAIN_CLAIMED` (409) and must be invited through the standard invitations flow. The public-email allowlist (`gmail.com`, `outlook.com`, etc.) is hard-coded. Those domains always get fresh personal accounts. Leave off for consumer products. See [Multi-tenant model → Domain claiming](/api/multi-tenant/#domain-claiming-optional-b2b-mode). ### Billing **Repo:** api. **Required:** no. When `true`, Stripe keys are required. **Repo:** api. **Required:** when billing is on. **Repo:** api. **Required:** when billing is on. **Repo:** api. **Required:** when billing is on. Used to upsert the template's Free and Pro plans at runtime. ### AI **Repo:** api. **Required:** no. **Repo:** api. **Required:** when AI is on. One of `openai`, `anthropic`, or `noop`. **Repo:** api. **Required:** when AI provider is openai. **Repo:** api. **Required:** no. Point at OpenAI-compatible APIs (OpenRouter, Ollama, vLLM). **Repo:** api. **Required:** no. Optional JSON object of extra headers passed on every OpenAI client request. Useful for OpenRouter ranking (`HTTP-Referer`, `X-Title`) or for custom auth on a compatible endpoint. Empty means no extra headers. **Repo:** api. **Required:** when AI provider is anthropic. ### UI build-time **Repo:** ui. **Required:** no (default empty). API base URL. Empty means same-origin (`/api/*` relative). Set a full HTTPS URL only for cross-origin deployments. **Repo:** ui. **Required:** no. CSV of locale codes; first entry is the fallback. **Repo:** ui. **Required:** yes (default `http://localhost:3001`). Public SPA origin used for billing return URLs, SEO, and share links. In same-origin deploys this matches the browser origin; cross-origin setups still need the UI's public URL here (not the API host). **Repo:** ui. **Required:** no (default empty). Web Push public key. Must be the same value as API `WEB_PUSH_VAPID_PUBLIC` — generate both sides together with `bun run vapid:generate` in apps/api. Empty hides the subscribe UI; the API also exposes `capabilities.features.notifications.webPush`. ### Infra (compose-only) **Repo:** infra. **Required:** yes. `dev` or `prod`. **Repo:** infra. **Required:** yes. Postgres bootstrap credentials. **Repo:** infra. **Required:** yes in prod. Apex DNS name for Traefik. Same-origin path routing serves both UI and `/api/*` from this single host. **Repo:** infra. **Required:** yes in prod. Let's Encrypt contact address. **Repo:** infra. **Required:** when observability overlay is on. **Repo:** infra. **Required:** no. Per-service resource caps; see [Resource limits](/infra/resource-limits/). **Repo:** infra. **Required:** no. Overlay toggles. **Repo:** infra. **Required:** when backups are configured. **Repo:** infra. **Required:** no. Default 30. **Repo:** infra. **Required:** no. `1` means the script logs but does not act. ## Authoritative references The sections above are an **index**. For defaults, comments, and per-var reasoning: [`.env.example`](https://github.com/boringstack-xyz/boringstack/blob/main/apps/api/.env.example) [`.env.example`](https://github.com/boringstack-xyz/boringstack/blob/main/apps/ui/.env.example) [`compose/.env.example`](https://github.com/boringstack-xyz/boringstack/blob/main/infra/compose/compose/.env.example) ## Related - [Env validator](/api/env-validator/); how the apps/api enforces required + cross-field rules. - [Secrets](/infra/secrets/); handling the sensitive subset. # Glossary > Project-specific terms used across the docs, defined in one place. import PageIntro from "../../../components/docs-kit/PageIntro"; import FaqGroup from "../../../components/FaqGroup.tsx"; import FaqItem from "../../../components/FaqItem.tsx"; Project-specific terms used across the docs, collected here so you do not have to hunt definitions page by page. ## Architecture vocabulary The folders that compose BoringStack's runtime: `apps/api`, `apps/ui`, and `infra/compose`. They live in one monorepo so Compose, CI, and docs can reference every layer through stable relative paths. `infra/bootstrap` provisions a VPS via OpenTofu and is optional. A single directory per feature (`src/api//`, `src/features//`). Holds the routes, services, types, and tests for that one feature. The unit of independent change. The apps/api ships `auth`, `users`, `billing`, `dashboard`, `admin`, `health` as framework features; no demo domain resource. [Read more](/architecture/monorepo-layout/). The API app's per-feature pattern. For a hypothetical `posts` resource: `posts.routes.ts` does HTTP only, `posts.service.ts` does business logic + DB, `posts.types.ts` holds the shapes shared between them. Enforced by ESLint. [Read more](/api/overview/). The UI app's per-component pattern. A page like `DashboardPage/` is a folder of ~8 files (`.tsx`, `.hooks.ts`, `.types.ts`, `.constants.ts`, `.utils.ts`, `.test.tsx`, `.stories.tsx`, `index.ts`). [Read more](/ui/overview/). The shape a hook returns to its component (`IDashboardPageView` returned by `useDashboardPage`). The component never reads queries, stores, or env directly; it only renders the view object. Decouples logic from JSX. The lint-enforced rule that a file can have one semantic concern. No mixing routes + services + utils in one file. Enforced by [`@boring-stack-pkg/eslint-plugin-module-boundaries`](https://www.npmjs.com/package/@boring-stack-pkg/eslint-plugin-module-boundaries). ## Infra vocabulary The deployment target: `STACK=dev` or `STACK=prod`. Picks which compose overlay (HTTP routes + host ports for dev, HTTPS + ACME for prod) gets merged on top of the base. An opt-in `docker-compose..yml` file that adds services to the base stack. Activated by a `WITH_=1` env var. Overlays compose freely (e.g. `WITH_OBSERVABILITY=1 WITH_GLITCHTIP=1`). [Read more](/infra/profiles-and-overlays/). A docker-compose feature for grouping services within a single file. The infra stack uses profiles (`--profile dev`, `--profile observability`) alongside overlays; profiles activate services within a file, overlays add files. The always-on services: Postgres + Valkey + api-migrate (one-shot) + the app containers. Traefik is in the prod profile only; dev uses Vite's dev-server proxy. Everything else is an overlay. Postgres + Valkey. The services that hold state. Deliberately not exposed to the `frontend` Docker network, so the only path from the world to the data plane is through the API. The BSD-licensed Redis-protocol-compatible store BoringStack uses for cache + queues. Drop-in compatible with Redis: same wire protocol, same client libraries (`ioredis`, BullMQ). Env vars are renamed `VALKEY_HOST` / `VALKEY_PORT` / `VALKEY_PASSWORD` / `VALKEY_DB` so the operator-facing names match the binary; third-party containers (bull-board, GlitchTip) still read their own `REDIS_*` env names internally, with our compose overlays bridging the value. Maintained by the Linux Foundation with AWS, Google, and Oracle as primary sponsors. ## Architecture rules vocabulary Shorthand for "the rules the ESLint plugins enforce." When something is "in the contract," violating it fails `bun run validate`. The phrase emphasizes that *the lint is load-bearing*, not the prose docs. [Read more](/architecture/lint-as-contract/). A call site that intentionally doesn't `await` a Promise. Used for audit-log writes and other telemetry where failure must never propagate to the caller. The `void` prefix marks the intent for both readers and the linter. An interface with multiple concrete implementations, selected by env var. Used for email, AI, and cache. The interface is the contract; the implementations are interchangeable. ## Data + persistence The TypeScript-first ORM the apps/api uses for Postgres. Schema-as-TS, migrations as generated SQL files, queries that look like SQL but are typed. Picked over Prisma because there's no shadow database and migrations are plain SQL. A namespace within a database. The apps/api uses two: `public` (app tables) and `audit` (the audit log). Keeping them separate lets you grant, archive, or migrate them independently. The append-only `audit.audit_log` table. Records security- and compliance-relevant events. Fire-and-forget; writes never block requests. [Read more](/api/audit-log/). ## Background work A named work buffer in Valkey, owned by a directory under `src/queues//`. Producers enqueue; workers consume. The directory follows a fixed pattern (constants, types, queue, worker, setup) so producer + consumer can't drift on names. The process-singleton that owns all queues + workers. Application code never imports BullMQ's `Queue` class directly; it calls `manager.enqueueX(...)`. [Read more](/api/queues/). A job that produces the same result if run twice. BullMQ retries on failure, so workers must be idempotent. Patterns: natural keys, UNIQUE constraints with caught violations, check-then-do inside a transaction. ## Other The infra orchestrator. Forwards every argument to `docker compose` with the right overlay + profile flags based on `STACK=` and `WITH_*=` env vars. Plain bash; you can read what it does. The merge gate. Typecheck + lint + tests. A PR can't merge if `validate` fails. The phrase "the merge gate" anywhere in the docs means this command. ## Related - [Repository layout](/architecture/monorepo-layout/); where the monorepo workspaces live and how they connect. - [Stack at a glance](/architecture/stack/); the dependency inventory behind the vocabulary. - [Profiles & overlays](/infra/profiles-and-overlays/); overlay vs profile in practice. - [Commands cheatsheet](/reference/commands/); what to type for each term. - [Lint as the contract](/architecture/lint-as-contract/); the rules these terms refer back to. # Scripts & tooling > What lives under scripts/ in apps/ui and apps/api, which package.json command runs each file, and what pre-push executes. import { Aside } from "@astrojs/starlight/components"; import PageIntro from "../../../components/docs-kit/PageIntro"; import ScriptsCatalog from "../../../components/docs-kit/ScriptsCatalog"; Prefer bun run commands over calling files directly. This page maps each wired command to the script under{" "} scripts/ and explains what that folder is for. ## apps/ui ## apps/api ## Related - [Commands cheatsheet](/reference/commands/) — infra flags and day-to-day workflow - [lint:meta rules](/architecture/lint-meta/) — static guardrails under `scripts/lint-meta/` - [Lint as the contract](/architecture/lint-as-contract/) — ESLint merge gate # Backups > Postgres → rclone off-site, with retention. Runs from cron; restore drills are mandatory. import PageIntro from "../../../components/docs-kit/PageIntro"; import CommandRun from "../../../components/docs-kit/CommandRun"; import DocCallout from "../../../components/DocCallout.tsx"; import FaqGroup from "../../../components/FaqGroup.tsx"; import FaqItem from "../../../components/FaqItem.tsx"; The infra template ships `scripts/backup-wrapper.example.sh`: `pg_dump` → gzip → `rclone copy` to a remote of your choice, with retention. Idempotent for cron: logs to stdout, exits non-zero on failure. ## What the script does ```mermaid flowchart LR pg["pg_dump --no-owner
against running container"] gz["gzip
local temp file"] rclone["rclone copy
to remote"] retention["delete remote dumps
> RETENTION_DAYS"] cleanup["remove local temp"] pg --> gz --> rclone --> retention --> cleanup ``` Plain bash. ~80 lines. Read it before pointing at production. ## Design choices One config talks to S3, B2, Storj, Wasabi, even your own host via SFTP. Saves bandwidth and storage; pg_dump compresses well. Logical dumps survive Postgres version upgrades; file-level does not. The remote is the source of truth for what exists. First run prints the plan; promote to real once you trust it. Cron mail capture surfaces failures automatically. ## What to configure In `compose/.env` (the script sources it): What to dump. The rclone remote alias (configured separately via `rclone config`). Path within the remote. Default 30. Print what would happen; skip side effects. ## Setting it up 1. Configure rclone once: `rclone config` on the host. Pick a remote (S3, B2, etc.), name it `backup` (or anything; match `RCLONE_REMOTE_NAME`). 2. Copy the wrapper and make it executable: 3. Dry-run first to verify the plan: 4. Real run: drop the flag, run once manually, confirm a file lands in the remote. 5. Schedule it. Drop a cron entry: ```bash # /etc/cron.d/backups; daily at 03:15 15 3 * * * root /path/to/infra/compose/scripts/backup-wrapper.sh ``` ## Retention math Default retention 30 backups: a month of point-in-time recovery. Custom; see below. Years of monthly snapshots, days of recent. For longer retention (compliance, year-over-year audits), run two crons with different `RCLONE_REMOTE_PATH` values: ```bash # Daily, 30 days 15 3 * * * ... RCLONE_REMOTE_PATH=daily BACKUP_RETENTION_DAYS=30 ... # Monthly, kept forever (no retention) 15 4 1 * * ... RCLONE_REMOTE_PATH=monthly BACKUP_RETENTION_DAYS=99999 ... ``` ## Restore test Run a restore test **every quarter**. If any step fails, the runbook is broken. Fix it before you need it. .sql.gz ./", "gunzip .sql.gz", "docker run --rm -d --name pg-restore -e POSTGRES_PASSWORD=test postgres:17", "cat .sql | docker exec -i pg-restore psql -U postgres", ]} output={[ { tone: "info", text: "backup-2026-05-22.sql.gz (3.4 MB)" }, { tone: "ok", text: "Downloaded backup-2026-05-22.sql.gz" }, { tone: "ok", text: "pg-restore container started" }, { tone: "ok", text: "Restore complete. Run sanity queries against pg-restore", }, ]} /> Sanity-query row counts in the main tables; `users`, `audit_log`, whatever's important. ## Encryption The script uploads gzip'd SQL; readable to anyone with access to the remote. Two patterns to encrypt: - Server-side at the remote. S3, B2, etc. all support encryption-at-rest. Easiest; you trust the provider. - Client-side via rclone crypt. `rclone config` a crypt-wrapped remote on top of the bucket. Encrypted before leaving the host. For sensitive data, client-side is the right answer. Document the password in your secret store; without it, the backups are useless. ## What's not backed up - Valkey. Cache + queues, not authoritative. If you can't rebuild it, that's an architecture problem, not a backup problem. - Audit log table. It is backed up because it's part of Postgres. Noting it because some templates treat audit separately. - `compose/.env`. Back up separately; it's small enough to live in a private secrets repo or password manager. - Container images. Pull from registry on restore; not part of the backup loop. ## Source [`scripts/backup-wrapper.example.sh`](https://github.com/boringstack-xyz/boringstack/blob/main/infra/compose/scripts/backup-wrapper.example.sh) · [`docs/backup-offsite.md`](https://github.com/boringstack-xyz/boringstack/blob/main/infra/compose/docs/backup-offsite.md) under `infra/compose`. ## Related - [Secrets](/infra/secrets/); back up `compose/.env` separately. - [Deployment](/topics/deployment/); where this script lives in the broader operational picture. # Cloudflare Email setup > End-to-end; enable Workers Paid, enable Email Service on your domain, capture the account ID, scope a token, smoke-test. import PageIntro from "../../../components/docs-kit/PageIntro"; import CommandRun from "../../../components/docs-kit/CommandRun"; import DocCallout from "../../../components/DocCallout.tsx"; import { Steps } from "@astrojs/starlight/components"; From a fresh Cloudflare account to sending mail via Cloudflare Email Service. One-time setup; later rotations are step 4 only. For _why_ Cloudflare is the default, see [Cloudflare Email Service](/topics/cloudflare-email/). ## Prerequisites - A Cloudflare account that owns (or proxies) the domain you'll send from. - Admin access to that account. - A real email address for the API token's audit trail. ## How the pieces fit ```mermaid flowchart LR paid["Workers Paid plan"] domain["Email Service enabled
on your domain"] dns["SPF / DKIM / DMARC
auto-provisioned"] acct["Account ID
scope for the endpoint"] token["API Token
Email Sending: Edit"] api["apps/api
EMAIL_PROVIDER=cloudflare"] paid --> domain --> dns domain --> acct --> api domain --> token --> api ```

Cloudflare Email setup chain: a Workers Paid plan unlocks the Email Service; enabling it on your domain auto-provisions SPF, DKIM, and DMARC; from there you capture the Account ID (used in the endpoint URL) and a scoped API token (used in the bearer header). The apps/api reads both via{" "} EMAIL_PROVIDER=cloudflare.

You need both the account ID (in the endpoint URL) and the API token (in the bearer header). Lose either and sends fail. ## Setup 1. Enable Workers Paid. Cloudflare dashboard → Workers & Pages → Plans → upgrade to Workers Paid. Email Service is bundled into this plan; there's no separate billing line. ([Current pricing](https://www.cloudflare.com/plans/developer-platform/).) 2. Enable Email Service on your domain. Dashboard → Email → Email Routing or Email Sending → enable for the domain. Cloudflare auto-provisions the required DNS records (SPF, DKIM, DMARC) because the zone is on Cloudflare. That removes the usual hand-edited DNS step, where most transactional-email setups go wrong. Wait for the dashboard to show all three records as Active (usually under a minute). 3. Capture the Account ID. Dashboard → any domain → right sidebar → "Account ID". 32 hex characters. Drop it into `compose/.env`: > compose/.env", ]} /> 4. Scope an API token. Dashboard → My Profile → API Tokens → Create Token → Custom Token with: - Permissions: `Email Sending: Edit` only, scoped to this account. - Account resources: include the specific account. - TTL: indefinite for now; rotate quarterly or after any staff change. Copy the token (you can't view it again). Drop it into `compose/.env`: > compose/.env", ]} /> 5. Set the sender + provider. > compose/.env", "echo 'EMAIL_FROM=noreply@yourdomain.com' >> compose/.env", ]} /> `EMAIL_FROM` must be on a domain you've enabled Email Service for. Sending from a domain that isn't enabled returns a 403. 6. Smoke-test. Success looks like `event="email_sent" provider="cloudflare"`. Failure logs the response body; usually an unverified-domain error or a permission-scope mistake. Brand-new Cloudflare accounts have a sender-verification lock: you can only send to addresses you've verified, until the account is approved for unrestricted sending. The check is usually automatic within a few days for legitimate use. If you need to ship before then: - **Verify recipient addresses:** In the dashboard under Email → verify recipient. - **Fast-track approval:** Contact Cloudflare support explaining your use case. ## Validating SPF / DKIM / DMARC Once the dashboard shows the records active, verify them from your terminal: All three should return a value. If any are empty, the Cloudflare auto-provision didn't complete; re-toggle Email Service in the dashboard. ## Rotation Every quarter, or after any staff change: 1. Create a new API token with the same scope. 2. Update `CLOUDFLARE_EMAIL_API_TOKEN` in `compose/.env`. 3. `./dev.sh restart api`. 4. Confirm a send works with the new token. 5. Revoke the old token in the dashboard. ## Switching to Resend / SendGrid The apps/api is provider-agnostic. Swapping is one env var: > compose/.env", "echo 'RESEND_API_KEY=re_your_resend_api_key' >> compose/.env", ]} /> The env validator refuses to boot in production if the matching key is missing. See [Email](/api/email/) and [Cloudflare Email Service](/topics/cloudflare-email/) for the abstraction. ## Source - Provider implementation: [`src/lib/email/providers/cloudflare.ts`](https://github.com/boringstack-xyz/boringstack/blob/main/apps/api/src/lib/email/providers/cloudflare.ts) in the apps/api. - Cloudflare's own docs: [developers.cloudflare.com/email-service](https://developers.cloudflare.com/email-service/). ## Related - [Cloudflare Email Service](/topics/cloudflare-email/); why it's the default and how it compares. - [Email](/api/email/); the pluggable provider abstraction behind every backend. - [Email in development](/topics/email-in-dev/); Mailpit when you don't want to hit a real provider. - [Env validator](/api/env-validator/); the boot-time check that catches a missing token before prod. # Env backup and secrets > Back up compose/.env to a password manager on your remote host. What is not in Postgres backups, rotation cadence, team handoff. import PageIntro from "../../../components/docs-kit/PageIntro"; import CommandRun from "../../../components/docs-kit/CommandRun"; import DocCallout from "../../../components/DocCallout.tsx"; import FaqGroup from "../../../components/FaqGroup.tsx"; import FaqItem from "../../../components/FaqItem.tsx"; import { Steps } from "@astrojs/starlight/components"; Your production secrets live in `compose/.env` on the VPS — not in git, not in Postgres backups. This runbook walks through exporting that file to a password manager secure note so you never lose JWT keys, DB passwords, or OAuth client secrets. ## Where env lives After [Provisioning with OpenTofu](/topics/provisioning-with-tofu/), cloud-init renders secrets to: ``` ~/infra/compose/compose/.env ``` Permissions are `0600` (owner read/write only). The same path applies whether you provisioned with Tofu or cloned the monorepo manually on Hetzner (or any VPS). **What is in this file:** `JWT_SECRET`, `POSTGRES_PASSWORD`, `VALKEY_PASSWORD`, OAuth client secrets, Stripe keys, Cloudflare tokens, `WUD_GHCR_TOKEN`, email provider keys, and every other runtime secret referenced by [`compose/.env.example`](https://github.com/boringstack-xyz/boringstack/blob/main/infra/compose/compose/.env.example). ## What Postgres backups do _not_ cover Nightly Postgres dumps (see [Backups](/runbooks/backups/)) capture database rows only. They **do not** include: - `compose/.env` itself - TLS private keys on disk (if stored outside the env file) - GHCR PATs or Cloudflare API tokens you rotate separately If you restore Postgres without a copy of `.env`, the database comes back but the app cannot authenticate users or send email until you reconstruct secrets. Some teams do. BoringStack defaults to password-manager backup because it avoids accidental pushes, works for non-git operators, and pairs well with team handoff. Either approach is fine — pick one and document it. Yes. See [Secrets management](/infra/secrets/) for the broader pattern. This runbook focuses on password-manager secure notes as the lowest-friction default. ## Backup workflow 1. **SSH to the VPS** and confirm the env file exists: 2. **Copy contents to clipboard** (never paste into public channels): 3. **Create a secure note** in your password manager: - **Title:** `BoringStack prod — example.com compose/.env` - **Fields:** paste full file; add `Last rotated:` date field - **Tags:** `boringstack`, `production`, `hetzner` (or your host) Recommended managers: 1Password, Bitwarden, Proton Pass. Use **Secure Note** type, not a login item. 4. **Verify restore drill** (quarterly): paste the note into a scratch `compose/.env` on a staging VPS and run `STACK=prod ./scripts/compose-up.sh config` — should render without missing-variable errors. ## Rotation cadence | Secret | Suggested rotation | Notes | | -------------------- | --------------------- | ----------------------------------------- | | `JWT_SECRET` | On compromise only | Rotating invalidates all sessions | | `POSTGRES_PASSWORD` | Annual | Requires coordinated compose + DB update | | OAuth client secrets | When provider prompts | Update provider console + `.env` | | `WUD_GHCR_TOKEN` | 90 days | PAT with `read:packages` scope | | Email API tokens | Per provider policy | Cloudflare / Resend / SendGrid dashboards | After every rotation: update `compose/.env` on the VPS, update the password-manager note, restart affected services. ## Team handoff When adding an operator: 1. Share the secure note via password-manager vault (not Slack/email). 2. Grant SSH access separately (see [Firewall & TLS](/runbooks/firewall-and-tls/)). 3. Point them to [OAuth provider setup](/runbooks/oauth-provider-setup/) for any manual credential steps. When offboarding: rotate every secret the departing operator had access to. Never store `compose/.env` in object storage, gist URLs, or ticket comments. Treat it like a root password. ## Related - [Secrets management](/infra/secrets/) - [Backups](/runbooks/backups/) - [Provisioning with OpenTofu](/topics/provisioning-with-tofu/) # Firewall & TLS (single-host) > UFW + Cloudflare IP allowlist + Traefik ACME for a single-VPS deployment. import PageIntro from "../../../components/docs-kit/PageIntro"; import CommandRun from "../../../components/docs-kit/CommandRun"; import DocCallout from "../../../components/DocCallout.tsx"; import { Steps } from "@astrojs/starlight/components"; Firewall + TLS for a single VPS sitting behind Cloudflare. Goal: only Cloudflare can reach port 80/443; SSH stays open from operator IPs; Traefik handles TLS termination via ACME HTTP-01. If you're provisioning via the [OpenTofu template](/topics/provisioning-with-tofu/), the Hetzner cloud firewall already enforces the Cloudflare-only allowlist at the provider level. This UFW path is for manually-provisioned hosts, or as defense-in-depth on top of the cloud firewall. The `infra/compose/scripts/ufw.example.sh` script **resets your UFW config**. Read it before pointing it at a real server. It refuses to run without `CONFIRM=yes`. ## Outcome - Inbound 22: SSH from anywhere (change `SSH_PORT` if you've moved it). - Inbound 80, 443: only from Cloudflare IP ranges (IPv4 + IPv6). - All other inbound: dropped. - Outbound: unrestricted. - Traefik on 80/443: HTTPS via Let's Encrypt ACME HTTP-01, cert renewal automatic, HTTP redirects to HTTPS. ## Prerequisites - Your domain's DNS is managed by Cloudflare (orange-cloud / proxied mode for the host records). - The server has a static IPv4 address. - You have an ACME contact email (a real address; Let's Encrypt rejects `example.com`). ## Apply 1. Edit `compose/.env`: > compose/.env", "echo 'ACME_EMAIL=ops@example.com' >> compose/.env", ]} /> The domain must resolve to this server (via a Cloudflare proxied A/AAAA record on the apex). BoringStack uses same-origin path routing: Traefik serves the SPA at `https://example.com/*` and the API at `https://example.com/api/*` + `https://example.com/health` on the same host and cert. 2. Boot the prod stack: Traefik starts requesting ACME certs on first boot. Watch `docker compose logs traefik` for `obtain certificate` events. 3. Run the firewall script: ## Verify https://example.com/health", ]} output={[ { tone: "ok", text: "HTTP/2 200 (local, self-signed accepted)" }, { tone: "ok", text: "HTTP/2 200 CF-Ray: abc123... (Cloudflare proxied)" }, { tone: "warn", text: "curl: (28) Connection timed out. Direct hit blocked by UFW ✓" }, ]} /> The third check is the proof: a direct hit to the server's IP, bypassing Cloudflare's edge, gets dropped by UFW. ## Rotation: when Cloudflare publishes new IP ranges Cloudflare publishes [IPv4](https://www.cloudflare.com/ips-v4) and [IPv6](https://www.cloudflare.com/ips-v6) ranges and updates them rarely (semi-annual at most). When they change, re-run the script. It resets to a clean state, re-fetches the current ranges, and re-applies. Idempotent. ## When the firewall is NOT enough UFW + Cloudflare-IP-allowlist protects you from random scans hitting your origin IP. It doesn't protect against: - Cloudflare-routed attacks (DDoS aimed at your hostname). Use Cloudflare's WAF + rate-limiting rules for that. - App-layer abuse (credential stuffing, scraping). Use Traefik's rate-limit middleware (already configured for the API router in prod) and per-account rate limits in the API. - Compromise via SSH. Move SSH to a non-standard port, disable password auth, use a hardware key. [`infra/compose/docs/security-hardening.md`](https://github.com/boringstack-xyz/boringstack/blob/main/infra/compose/docs/security-hardening.md) has a full checklist. ## Related - [Deployment](/topics/deployment/); the production sequence this firewall sits inside. - [Provisioning with OpenTofu](/topics/provisioning-with-tofu/); cloud-firewall path that complements UFW. - [Security pipeline](/topics/security/); SAST and supply-chain scans alongside this network hardening. - [Backups](/runbooks/backups/); the other half of "ready for prod." - [Image updates](/runbooks/image-updates/); patching cadence for the stack you just hardened. # Image updates > WUD (What's Up Docker) defaults on in production and runs a hybrid policy: auto-deploy app images, notify-only for base images. import PageIntro from "../../../components/docs-kit/PageIntro"; import CommandRun from "../../../components/docs-kit/CommandRun"; import FaqGroup from "../../../components/FaqGroup.tsx"; import FaqItem from "../../../components/FaqItem.tsx"; In production (`STACK=prod`), `WITH_WUD=1` is the default and enables [WUD](https://getwud.github.io/wud/). WUD watches running images and applies a hybrid policy: `api` and `ui` auto-pull + auto-recreate on new GHCR tags, while base services stay notify-only for operator review. ## Hybrid policy WUD uses a Docker trigger for app containers. When GHCR publishes a newer image tag, WUD pulls and recreates those containers automatically. WUD reports updates, but does not recreate base services. You review release notes, schedule maintenance, then update manually. Dashboard at `:3033` always works. Discord and Slack notifications are enabled only when the corresponding env vars are set. ## Setup 1. Optional notifications in `compose/.env` (Discord, Slack, or both): ```dotenv WUD_DISCORD_WEBHOOK=https://discord.com/api/webhooks/... WUD_SLACK_BOT_TOKEN=xoxb-... WUD_SLACK_CHANNEL=docker-updates ``` 2. Optional private GHCR auth: ```dotenv WUD_GHCR_USERNAME=your-gh-username WUD_GHCR_TOKEN=ghp_xxx ``` 3. Optional schedule override: ```dotenv WUD_SCHEDULE="0 */6 * * *" ``` 4. Boot production stack: ## Operational flow 1. A push that touches `apps/api` or `apps/ui` publishes new GHCR image tags. 2. WUD detects tag movement on schedule. 3. App containers auto-pull + auto-recreate. 4. Base-image updates only generate notifications. ## Manual base-image update For Postgres, Valkey, and Traefik: 1. Read upstream release notes. 2. Take required backups. 3. Bump pinned tag(s) in infra compose files. 4. Pull and recreate the specific service. ## Disable WUD ```bash WITH_WUD=0 STACK=prod ./scripts/compose-up.sh ``` ## Source [`infra/compose/docs/image-update-detection.md`](https://github.com/boringstack-xyz/boringstack/blob/main/infra/compose/docs/image-update-detection.md) · [WUD docs](https://getwud.github.io/wud/) ## Related - [Deployment](/topics/deployment/) - [Profiles & overlays](/infra/profiles-and-overlays/) # OAuth provider setup > Create Google, GitHub, and LinkedIn OAuth apps — redirect URIs, scopes, and env var mapping for apps/api. import PageIntro from "../../../components/docs-kit/PageIntro"; import CommandRun from "../../../components/docs-kit/CommandRun"; import DocCallout from "../../../components/DocCallout.tsx"; import { Steps } from "@astrojs/starlight/components"; Step-by-step console walkthroughs for each OAuth provider BoringStack supports. Credentials map to env vars consumed by [`oauth.manifest.ts`](https://github.com/boringstack-xyz/boringstack/blob/main/apps/api/src/lib/oauth/oauth.manifest.ts). Set these base URLs before creating apps (replace `example.com` with your domain): | Variable | Example | | ---------------- | ------------------------- | | `FRONTEND_URL` | `https://example.com` | | `PUBLIC_API_URL` | `https://example.com/api` | OAuth callback URL pattern for all providers: ``` https://example.com/api/v1/auth/oauth/{provider}/callback ``` Where `{provider}` is `google`, `github`, or `linkedin`. --- ## Local development For the Docker Compose dev stack (`api-dev` on port 3000, Vite UI on 3001), register **additional** redirect URIs in each provider console: ``` http://localhost:3000/api/v1/auth/oauth/google/callback http://localhost:3000/api/v1/auth/oauth/github/callback http://localhost:3000/api/v1/auth/oauth/linkedin/callback ``` API secrets go in `infra/compose/compose/.env` (same `GOOGLE_OAUTH_*` / `GITHUB_OAUTH_*` / `LINKEDIN_OAUTH_*` keys as production). OAuth login buttons render when the API has credentials for a provider — the UI reads `GET /api/v1/capabilities/` and shows buttons for each entry in `oauth.providers`. No UI-side OAuth client IDs are required. After changing env, restart `api-dev` and the Vite dev server. When `VITE_API_URL` is empty, the browser uses same-origin relative `/api` paths and Vite proxies to `VITE_API_PROXY_TARGET` (default `http://localhost:3000`). Callback URLs still hit the API on port 3000, not the Vite port. --- ## Google 1. Open [Google Cloud Console](https://console.cloud.google.com/) → **APIs & Services** → **Credentials**. 2. **Create OAuth client ID** → Application type: **Web application**. 3. **Authorized redirect URIs** — add exactly: ``` https://example.com/api/v1/auth/oauth/google/callback ``` 4. **Scopes** (requested automatically by BoringStack): `openid`, `email`, `profile`. 5. Copy **Client ID** and **Client secret** into `compose/.env`: > compose/.env", "echo 'GOOGLE_OAUTH_CLIENT_SECRET=your-client-secret' >> compose/.env", ]} /> 6. Restart the API container after updating env. --- ## GitHub 1. GitHub → **Settings** → **Developer settings** → **OAuth Apps** → **New OAuth App**. 2. **Homepage URL:** `https://example.com` 3. **Authorization callback URL:** ``` https://example.com/api/v1/auth/oauth/github/callback ``` 4. **Scopes** (requested by BoringStack): `read:user`, `user:email`. 5. Generate a **Client secret** and add to `compose/.env`: > compose/.env", "echo 'GITHUB_OAUTH_CLIENT_SECRET=your-github-client-secret' >> compose/.env", ]} /> --- ## LinkedIn 1. [LinkedIn Developer Portal](https://www.linkedin.com/developers/) → **Create app**. 2. Under **Auth** → **OAuth 2.0 settings** → **Authorized redirect URLs:** ``` https://example.com/api/v1/auth/oauth/linkedin/callback ``` 3. Request **Sign In with LinkedIn using OpenID Connect** product (required for `openid` scope). 4. **Scopes:** `openid`, `profile`, `email`. 5. Add credentials to `compose/.env`: > compose/.env", "echo 'LINKEDIN_OAUTH_CLIENT_SECRET=your-linkedin-client-secret' >> compose/.env", ]} /> --- ## Verify 1. Ensure Valkey is running (OAuth state store requires it). 2. Hit `GET /api/v1/capabilities/` — `oauth.providers` should list configured providers. 3. Open the login page — OAuth buttons appear only for providers with valid env vars. 4. Complete a login flow; you should land on `/oauth/success` then `/dashboard`. For Tofu provisioning, add the same values to `terraform.tfvars` as `google_oauth_client_id`, `github_oauth_client_id`, `linkedin_oauth_client_id` (and matching secrets). See [`terraform.tfvars.example`](https://github.com/boringstack-xyz/boringstack/blob/main/infra/bootstrap/terraform.tfvars.example). ## Related - [Authentication](/api/auth/) - [Environment variables](/reference/env-vars/) - [Env backup and secrets](/runbooks/env-backup-and-secrets/) # Cloudflare Email Service > Why we default to Cloudflare's transactional email API. The cheapest deliverability path that exists at non-trivial volume. import PageIntro from "../../../components/docs-kit/PageIntro"; import DataMatrix from "../../../components/docs-kit/DataMatrix"; import CommandRun from "../../../components/docs-kit/CommandRun"; import DocCallout from "../../../components/DocCallout.tsx"; The apps/api ships [Cloudflare Email Service](https://developers.cloudflare.com/email-service/) as the default outbound mail provider. Bundled into Workers Paid with no documented per-message charge, it is usually the cheapest transactional path at non-trivial volume among the providers wired into this template. ## Why it's the default Pricing tiers, free-tier limits, and per-message rates all evolve. See [Cloudflare](https://developers.cloudflare.com/email-service/) · [Resend](https://resend.com/pricing) · [SendGrid](https://sendgrid.com/en-us/pricing) for current numbers. If you're already paying for Workers, Cloudflare Email has no per-message cost. BoringStack defaults to it because marginal cost is zero. ## Integration The API app's email layer is pluggable: one `IEmailService` interface, five concrete implementations (Cloudflare, Resend, SendGrid, SMTP, noop). The Cloudflare provider POSTs to an account-scoped endpoint on `api.cloudflare.com` with a bearer token. Retries, structured logging, and the shared `retryWithBackoff` wrapper apply equally to all providers; Cloudflare gets the same reliability treatment as Resend. For local template iteration, set `EMAIL_PROVIDER=smtp` with `SMTP_HOST=mailpit` and the [Mailpit overlay](/topics/email-in-dev/) catches everything for inspection. See [Email](/api/email/) for the abstraction shape and how dispatch works. ## Dev quickstart Dev with no Cloudflare keys → the noop provider logs the rendered payload to stdout instead of sending. To actually send from dev, inject the following secrets: > compose/.env", "echo 'EMAIL_FROM=noreply@yourdomain.com' >> compose/.env", "echo 'CLOUDFLARE_ACCOUNT_ID=your_account_id' >> compose/.env", "echo 'CLOUDFLARE_EMAIL_API_TOKEN=your_scoped_api_token' >> compose/.env", ]} /> ## Production setup The step-by-step walkthrough lives in the [Cloudflare Email setup runbook](/runbooks/cloudflare-email-setup/): enable Workers Paid, enable Email Service on your domain, capture the account ID, scope a token, smoke-test. ## Switching to Resend or SendGrid Change env vars to switch to Resend or SendGrid: > compose/.env", "echo 'RESEND_API_KEY=re_your_resend_api_key' >> compose/.env", ]} /> Or for SendGrid: > compose/.env", "echo 'SENDGRID_API_KEY=SG.your_sendgrid_key' >> compose/.env", ]} /> The [env validator](/api/env-validator/) refuses to boot in production if the matching key is missing; switching providers is a one-redeploy operation, not a code change. ## Caveats - Cloudflare Email Service is in beta. Pricing may evolve; daily limits are variable and account-scoped. - DNS must be on Cloudflare. Auto-provisioned SPF/DKIM/DMARC are how deliverability works; you can't skip the step. - New accounts can only send to verified addresses until upgraded. The runbook covers the unlock. ## Related - [Cloudflare Email setup](/runbooks/cloudflare-email-setup/); the end-to-end walkthrough. - [Email](/api/email/); the pluggable provider abstraction. - [Email in development](/topics/email-in-dev/); Mailpit when you want to iterate without sending. - [Env validator](/api/env-validator/); the boot-time check that pairs with provider config. # Deployment > Boring deployment. Single-host VPS, Docker, one monorepo fork, GHCR images, one prod profile. HTTPS via ACME, perimeter locked by Cloudflare. import { Aside } from "@astrojs/starlight/components"; import CommandRun from "../../../components/docs-kit/CommandRun"; import PageIntro from "../../../components/docs-kit/PageIntro"; import SignalGrid from "../../../components/docs-kit/SignalGrid"; import FaqGroup from "../../../components/FaqGroup.tsx"; import FaqItem from "../../../components/FaqItem.tsx"; BoringStack runs on a VPS you control: Docker Compose, GHCR images, Traefik TLS, and Cloudflare at the edge. Fork the monorepo, fill compose/.env, then boot the production profile manually or let OpenTofu do first provisioning. ## The mental model Push to `main` with changes under `apps/api` or `apps/ui`. Path-filtered release workflows build Docker images and push them to `ghcr.io//-api:latest` and `ghcr.io//-ui:latest`. WUD on the VPS detects the new tags and auto-deploys app containers (`api`, `ui`). Base images remain notify-only and operator-applied. Traefik handles TLS via Let's Encrypt; Cloudflare proxies all traffic to your single apex domain via same-origin path routing. ## Design choices One VPS carries real traffic for years; scale out when you need it. Reproducible deploys; the VPS pulls images instead of running `npm install` on box. `api`/`ui` auto-deploy from GHCR tags; base services stay notify-only and human-reviewed. TLS on the origin without a separate load-balancer bill. One cert for `/` and `/api/*`; no `api.` subdomain. Only Cloudflare reaches the origin; port scans hit a closed firewall. Small runtime image without a shell in prod. ## First-time wiring Three things need configuring before the first deploy. The release workflows handle everything else from your monorepo fork URL. ### 1. Publish images to GHCR The API and UI release workflows run on push to `main` when their app paths change. They use `${{ github.event.repository.name }}` plus an app suffix, so a fork named `acme-stack` publishes `ghcr.io//acme-stack-api:latest` and `ghcr.io//acme-stack-ui:latest` automatically. The default `GITHUB_TOKEN` has `packages: write` via the workflow grant, so the first push from a freshly-forked repo works without secret setup. After the first publish, **make the GHCR package public** so the VPS can pull without credentials: 1. Go to the fork's Packages tab and open both containers: `-api` and `-ui`. 2. Package settings → Change visibility → Public. Without this, downstream consumers (your VPS) need a pull credential. ### 2. Point the prod compose stack at your images In `infra/compose/compose/.env`: ```bash IMAGE_OWNER=acme API_IMAGE_NAME=acme-stack-api UI_IMAGE_NAME=acme-stack-ui ``` Kept the canonical upstream repo name? Set `IMAGE_OWNER`; the defaults are `boringstack-api` and `boringstack-ui`. ### 3. (Optional) Wire OpenTofu bootstrap If using the OpenTofu path, `terraform.tfvars` under `infra/bootstrap`: ```hcl monorepo_repo = "https://github.com/acme/acme-stack" domain = "acme.com" # ... other vars per terraform.tfvars.example ``` The module derives `IMAGE_OWNER`, `API_IMAGE_NAME=-api`, and `UI_IMAGE_NAME=-ui` from that URL and renders them into `compose/.env` on the VPS. You don't set them twice. ## First-deploy checklist Use this once, after Quickstart and before the first production boot: - [ ] BoringStack monorepo forked under your org/user. - [ ] First API and UI images published to GHCR (push to `main`, then watch each Actions tab). - [ ] GHCR packages set to **Public**, or the VPS has a pull credential. - [ ] `compose/.env` has `IMAGE_OWNER` and any renamed `API_IMAGE_NAME` / `UI_IMAGE_NAME`. - [ ] `JWT_SECRET` regenerated for production (`openssl rand -base64 48`). - [ ] `FRONTEND_URL`, `PUBLIC_API_URL`, `PUBLIC_UI_HOST`, and `ACME_EMAIL` match the production origin. - [ ] `SUPERUSER_PASSWORD` rotated through the password-reset flow after first login. - [ ] [Firewall & TLS](/runbooks/firewall-and-tls/) verified: Cloudflare can reach the origin, direct requests cannot. - [ ] [Backups](/runbooks/backups/) configured and at least one restore drill run. - [ ] [Cloudflare Email setup](/runbooks/cloudflare-email-setup/) completed if Cloudflare is your outbound mail provider. - [ ] Optional OpenTofu path: `terraform.tfvars` has fork URLs, real `domain`, and production secrets. ## First-time deploy The preferred path is [Provisioning with OpenTofu](/topics/provisioning-with-tofu/): one `tofu apply` provisions the VPS, configures Cloudflare DNS + zone settings, and runs cloud-init which clones the monorepo, drops a rendered `compose/.env`, and pulls the GHCR images. Manual path if you'd rather: 1. Provision a Ubuntu VPS with Docker installed. 2. Clone your monorepo fork onto the VPS. 3. Write `compose/.env` with `PUBLIC_UI_HOST`, `ACME_EMAIL`, `IMAGE_OWNER`, and the rest from `.env.example`. 4. `STACK=prod ./scripts/compose-up.sh pull && STACK=prod ./scripts/compose-up.sh up -d`. 5. Run the [Firewall & TLS runbook](/runbooks/firewall-and-tls/) to verify Cloudflare-only ingress. You do **not** clone `apps/api` or `apps/ui` on the VPS. Their built images come from GHCR. ## Subsequent deploys For changes to the api or the ui, you don't touch the VPS: and semver tags when present" }, ]} /> WUD watches `latest`. App containers (`api`, `ui`) are updated automatically. For base-image updates and infra changes, apply manually: ", "cd /opt/boringstack/infra", "docker compose pull", "docker compose up -d", ]} /> For changes to the infra YAML or env vars, `git pull` the monorepo on the VPS then re-run `compose up -d`. ## Image update strategy `WITH_WUD=1` in prod: app images auto-pull + auto-recreate; base images send notifications only. Disable app auto-deploy labels if you want full manual rollouts. Set `API_IMAGE_TAG=sha-abc1234` or `:0.3.0` in `compose/.env` and update deliberately. The default is hybrid because app-image rollouts are low-risk and frequent, while base-image rollouts carry higher migration risk. See [Image updates](/runbooks/image-updates/). ## Rollback ```bash # On the VPS: echo "API_IMAGE_TAG=sha-" >> compose/.env # or a previous : docker compose pull docker compose up -d ``` Postgres schema is the only thing this doesn't roll back. Destructive migrations are forward-only by convention. The API app discipline is "additive changes are normal, destructive changes are deliberate," so most rollbacks just work. For high-stakes deploys, snapshot Postgres before applying a destructive migration. See [Backups](/runbooks/backups/). ## Where to scale up When single-host runs out of room, Postgres is almost always the bottleneck. In rough order: 1. Vertical: bigger VPS, bump `POSTGRES_LIMITS_*`. 2. Managed Postgres: Neon, Supabase, Crunchy, RDS. App stays on the VPS. 3. Replicate the API horizontally behind a real load balancer. 4. Kubernetes. Separate template planned; not in this repo because mixing Compose + cluster YAML is exactly the confusion BoringStack avoids. ## Provider notes EU-friendly pricing and network. New accounts may sit in fraud review for a day or two. Low cost with IPv4 included. Prefer the API over the web console for automation. Polished UX and docs. Compare specs against Hetzner/OVH for your workload. Lowest cost at scale; you operate the hardware and networking. Cloudflare in front is standard regardless of provider; that's what makes the firewall + IP allowlist work. ## Related runbooks - [Firewall & TLS](/runbooks/firewall-and-tls/). Cloudflare-only ingress, HTTPS via Traefik. - [Backups](/runbooks/backups/). Postgres + rclone. - [Image updates](/runbooks/image-updates/). WUD hybrid defaults. - [Cloudflare Email setup](/runbooks/cloudflare-email-setup/). Outbound mail. # Email in development > Iterate on transactional email templates and flows locally without sending real mail. Capture every outbound message in Mailpit's web UI. import PageIntro from "../../../components/docs-kit/PageIntro"; import CommandRun from "../../../components/docs-kit/CommandRun"; import { Steps } from "@astrojs/starlight/components"; Iterate on transactional email templates and signup flows locally without sending real mail. BoringStack includes Mailpit: SMTP on :1025, web UI on :8025. Test verify-email, password-reset, welcome, and notification flows locally without sending through Cloudflare Email Service, Resend, or SendGrid. ## Boot Mailpit 1. Start the dev stack with the Mailpit overlay: ```bash WITH_MAILPIT=1 ./dev.sh up -d ``` Adds two host ports: `1025` (SMTP) and `8025` (web UI). 2. Point the api at the catcher. In `infra/compose/compose/api.dev.env` (create if it doesn't exist): ```bash EMAIL_PROVIDER=smtp SMTP_HOST=mailpit SMTP_PORT=1025 # SMTP_USER + SMTP_PASS are optional. Mailpit accepts any. ``` 3. Restart the api so it picks up the new env: ```bash ./dev.sh restart api-dev ``` 4. Trigger an email: sign up, hit forgot-password, anything that emits transactional mail. 5. Open the web UI: ```bash open http://localhost:8025 ``` Full HTML, plain-text alt, headers, raw source. ## Use this for - Designing a new email template. Edit the Handlebars source under `apps/api/src/templates/email/`, trigger a flow, refresh Mailpit. - Debugging delivery locally. Mailpit captures the full SMTP envelope, so you can verify From / Reply-To / List-Unsubscribe before going near a real provider. - E2E tests that exercise email. Point the api at Mailpit and assert against the API surface (`http://localhost:8025/api/v1/messages`). No mail leaves the host. ## Don't use this for - Validating deliverability or spam scores. Mailpit doesn't send; you need a real provider in staging. - Load-testing the email pipeline. Mailpit defaults cap at 5,000 retained messages. ## Production fallback The same `EMAIL_PROVIDER=smtp` provider works against any RFC-5321 server (Postfix sidecar, SES via SMTP, Postmark). Set `SMTP_HOST` / `SMTP_PORT` / `SMTP_USER` / `SMTP_PASS` in `api.prod.env`. ## Related - [Email templates](/api/email/). Handlebars partials, precompiled templates. - [Profiles & overlays](/infra/profiles-and-overlays/). Every `WITH_*` flag. # Error tracking > One Sentry-compatible wire protocol, two backends. Hosted Sentry or self-hosted GlitchTip; swap by changing the DSN. import PageIntro from "../../../components/docs-kit/PageIntro"; import CommandRun from "../../../components/docs-kit/CommandRun"; import DocCallout from "../../../components/DocCallout.tsx"; import FaqGroup from "../../../components/FaqGroup.tsx"; import FaqItem from "../../../components/FaqItem.tsx"; Both `apps/api` and `apps/ui` ship Sentry SDKs configured for zero overhead. Swap between Hosted Sentry and self-hosted GlitchTip with a single DSN key. Both `apps/api` and `apps/ui` ship a Sentry SDK. The same SDK talks to: - **Hosted [Sentry](https://sentry.io)**; fastest setup, paid past the free tier. - **Self-hosted [GlitchTip](https://glitchtip.com)**; Sentry-API-compatible, runs as an overlay in the infra stack (`WITH_GLITCHTIP=1`). Zero per-event cost. The SDKs don't know which one they're talking to. Choosing is a one-env-var change. ## Design choices Same SDK interface and error format across both services. Wire-compatible with Sentry; runs on the same Postgres + Valkey the app already uses. Dev stays clean; tests do not ship error reports. Captures the broken flow without storing healthy sessions. Same protocol regardless of backend; swap is one redeploy. ## How it's wired ```mermaid flowchart LR api["apps/api
@sentry/bun"] -- "events" --> backend{DSN points where?} ui["apps/ui
@sentry/react"] -- "events + replays-on-error" --> backend backend -- "https://...sentry.io/..." --> sentry["hosted Sentry"] backend -- "https://...glitchtip.localhost/..." --> glitchtip["self-hosted GlitchTip"] ```

Both apps/api (@sentry/bun) and apps/ui (@sentry/react) emit events to the same Sentry-compatible wire protocol. The DSN env var picks the destination: a sentry.io hostname for hosted Sentry, or a{" "} glitchtip.localhost hostname for the self-hosted overlay. The SDKs don't know which one they're talking to.

API side: Sentry initialises once at boot. If `SENTRY_DSN` is empty, init is a no-op. The shared `captureError` helper is wired into unhandled-rejection and uncaught-exception handlers, so anything that escapes the request loop reaches the backend. UI side: Sentry initialises once at app mount when `VITE_SENTRY_DSN` is set. Replays-on-error capture the error context; full-session replays are off to avoid capturing video of every session. ## Self-hosting with GlitchTip GlitchTip is Apache-licensed and Sentry-API-compatible. The infra stack provides it as an overlay: ```bash WITH_GLITCHTIP=1 ./dev.sh up -d ``` First boot bootstraps a superuser, a default org, and two projects (`API` and `Frontend`). Visit `http://glitchtip.localhost`, grab each project's DSN, and drop them into the matching env vars. The overlay reuses the base stack's Postgres (in a separate `glitchtip` database) and Valkey (DB 1). Adding GlitchTip costs two extra containers, not a separate database server. For production hardening (Basic Auth, HTTPS, real SMTP), see the [GlitchTip docs under `infra/compose`](https://github.com/boringstack-xyz/boringstack/blob/main/infra/compose/docs/glitchtip.md). ## Switching backends Change only the DSN: API: `SENTRY_DSN=https://...@sentry.io/...`. UI: `VITE_SENTRY_DSN=https://...@sentry.io/...`. API: `SENTRY_DSN=https://...@glitchtip.example.com/...`. UI: `VITE_SENTRY_DSN=https://...@glitchtip.example.com/...`. No SDK changes. The infrastructure is the variable, not the code. **Hosted Sentry:** Best if you'd rather delegate the operations and pay a monthly subscription fee past the free tier. **GlitchTip:** Best if you are already running your own self-hosted stack and another container overhead is essentially free. The developer experience is identical on both paths because they use the same SDK. ## Source - API init: [`src/config/sentry.ts`](https://github.com/boringstack-xyz/boringstack/blob/main/apps/api/src/config/sentry.ts) + [`src/config/error-handlers.ts`](https://github.com/boringstack-xyz/boringstack/blob/main/apps/api/src/config/error-handlers.ts). - UI init: [`src/app/main.tsx`](https://github.com/boringstack-xyz/boringstack/blob/main/apps/ui/src/app/main.tsx). - GlitchTip overlay: [`compose/docker-compose.glitchtip.yml`](https://github.com/boringstack-xyz/boringstack/blob/main/infra/compose/compose/docker-compose.glitchtip.yml). ## Related - [Observability](/topics/observability/); metrics and logs that sit alongside error events. - [Profiles & overlays](/infra/profiles-and-overlays/); how `WITH_GLITCHTIP=1` actually composes onto the stack. - [Security pipeline](/topics/security/); the broader telemetry surface this fits into. - [Notifications](/api/notifications/); how user-facing errors are surfaced back into the app. # Observability > Prometheus + Grafana + Loki + Promtail overlay. Pre-provisioned dashboards, structured logs labeled per container, zero per-event cost. import PageIntro from "../../../components/docs-kit/PageIntro"; import CommandRun from "../../../components/docs-kit/CommandRun"; import FaqGroup from "../../../components/FaqGroup.tsx"; import FaqItem from "../../../components/FaqItem.tsx"; `WITH_OBSERVABILITY=1` drops a complete metrics-and-logs stack into your local environment. System, API, Postgres, and Valkey telemetry run on the same host. `WITH_OBSERVABILITY=1` adds a full metrics-and-logs stack to the infra. It runs on the same host as the app; no SaaS, no per-event billing. The default configuration covers debugging production incidents: app metrics, system metrics, container metrics, and all container logs queryable in one Grafana. ## How signals flow ```mermaid flowchart LR api["apps/api
/metrics endpoint"] pg["postgres-exporter"] valkey_exp["valkey-exporter"] node["node-exporter
host metrics"] prom["Prometheus
scrape every 15s"] containers["every container
stdout/stderr"] promtail["Promtail
per-container labels"] loki["Loki
log store"] grafana["Grafana
dashboards + queries"] alert["Alertmanager
routes alerts"] api --> prom pg --> prom valkey_exp --> prom node --> prom containers --> promtail --> loki prom --> grafana loki --> grafana prom --> alert ``` ## Design choices Zero per-event cost; data stays on your infra. Two well-known projects beat one unfamiliar abstraction. "Show me api-prod errors in the last hour" is one filter, not a regex. Stack boots usable; no manual wiring after `up -d`. Pager strategy is project-specific; the plumbing is there when you need it. Standard metrics for incident response. ## What you get out of the box Host `:3010`. Default admin / change-me (override via env). Internal. Configurable retention (see `compose/prometheus/prometheus.yml`). Internal. Per-container labels; configurable retention. Sidecar. Scrapes `/var/lib/docker/containers/*.log`. Host metrics: CPU, memory, disk, network. Postgres metrics: connections, transactions, table sizes. Valkey metrics: hit ratio, evictions, memory. Internal. Routes defined in `alertmanager/`. Drop dashboard JSON exports into `compose/grafana/dashboards/` and Grafana auto-loads them within 30s. Datasources and the dashboard provider are already wired in `compose/grafana/provisioning/`. ## Querying **Metrics (Prometheus, PromQL):** ```bash # 5-minute API request rate by route rate(http_requests_total[5m]) by (route) # Postgres connection count pg_stat_database_numbackends{datname="app"} # Worker job throughput rate(bullmq_jobs_completed_total[1m]) ``` See the [`infra/compose` PromQL cheatsheet](https://github.com/boringstack-xyz/boringstack/blob/main/infra/compose/docs/promql-cheatsheet.md) for more. **Logs (Loki, LogQL):** ```bash # All API errors in the last hour {container="api-prod"} |= "level=error" # Worker jobs by status {container=~"api.*"} | json | event="email_delivery_completed" ``` See the [LogQL cheatsheet](https://github.com/boringstack-xyz/boringstack/blob/main/infra/compose/docs/logql-cheatsheet.md). ## Adding an alert 1. Drop a rule file in `compose/prometheus/rules/` (e.g. `api-error-rate.yml`). 2. Configure routing in `compose/alertmanager/alertmanager.yml`. 3. `docker compose restart prometheus alertmanager`. Alertmanager can route to Slack, email, PagerDuty, or a webhook. The template ships the structure; you fill in your receiver. ## Adding a custom app metric The API app's `/metrics` endpoint uses the standard Prometheus client library. Define a counter / gauge / histogram in `src/lib/metrics/`, increment it from your code, and Prometheus picks it up on the next scrape. The convention is one file per metric domain (`http-metrics.ts`, `queue-metrics.ts`). ## Cost The overlay is light on memory at the default sizing; see [Resource limits](/infra/resource-limits/) for the per-service knobs. Disk grows with retention windows; tune them in `compose/prometheus/prometheus.yml` and `compose/docker-compose.observability.yml` if storage matters. ## Source [`compose/docker-compose.observability.yml`](https://github.com/boringstack-xyz/boringstack/blob/main/infra/compose/compose/docker-compose.observability.yml) · [`compose/prometheus/`](https://github.com/boringstack-xyz/boringstack/tree/main/infra/compose/compose/prometheus) · [`compose/grafana/`](https://github.com/boringstack-xyz/boringstack/tree/main/infra/compose/compose/grafana) · [`compose/promtail/`](https://github.com/boringstack-xyz/boringstack/tree/main/infra/compose/compose/promtail). ## Related - [Error tracking](/topics/error-tracking/); Sentry/GlitchTip for exceptions specifically. - [Resource limits](/infra/resource-limits/); what you'll watch with these dashboards. # Provisioning with OpenTofu > Optional OpenTofu bootstrap for the monorepo. One `tofu apply` provisions a Hetzner VPS, configures Cloudflare DNS, and bootstraps the docker-compose stack. import PageIntro from "../../../components/docs-kit/PageIntro"; import CommandRun from "../../../components/docs-kit/CommandRun"; import DocCallout from "../../../components/DocCallout.tsx"; import FaqGroup from "../../../components/FaqGroup.tsx"; import FaqItem from "../../../components/FaqItem.tsx"; One declarative `tofu apply` provisions a Hetzner VPS, configures Cloudflare DNS firewalls, installs Docker runtime, and bootstraps your full-stack docker-compose environment. BoringStack's [Deployment](/topics/deployment/) path is manual: SSH into a VPS, install Docker, clone the monorepo, `compose pull && compose up -d`. Some operators prefer this. The OpenTofu stack is the alternative for people who'd rather drive the same outcome from a single declarative apply. Source lives in [`infra/bootstrap`](https://github.com/boringstack-xyz/boringstack/tree/main/infra/bootstrap). ## What it does Starting with a domain on Cloudflare, a Hetzner account, and a filled `terraform.tfvars`: Run `tofu apply`. Minutes later, the site is live at `https://`: VPS provisioned, DNS configured, HTTPS valid, optional superuser seeded (when `superuser_email` + `superuser_password` are set). ## What one apply does ```mermaid flowchart LR tfvars["terraform.tfvars
tokens · domain · sizing"] apply["tofu apply"] hetz["Hetzner
VPS · SSH key · firewall"] cf["Cloudflare
A records · zone settings"] init["cloud-init on first boot
installs Docker + git
runs bootstrap.sh"] script["bootstrap.sh from repo
clones monorepo
renders compose/.env
compose pull && compose up -d"] live["live site"] tfvars --> apply apply --> hetz apply --> cf hetz --> init --> script --> live cf -.->|DNS resolves| live ``` ## Prerequisites Registered at Cloudflare Registrar, or NS-pointed to Cloudflare. Sign up at [Hetzner Cloud](https://www.hetzner.com), payment method on file. Hetzner Cloud Console, project, Security, API Tokens, Read and Write. Cloudflare, My Profile, API Tokens, Custom Token. Scope: `Zone:DNS:Edit`, `Zone:Zone Settings:Edit`, `Zone:Rulesets:Edit` on the target zone. The zone overview page in the dashboard, right sidebar. `ssh-keygen -t ed25519` if you do not have one; paste the `.pub` contents. `brew install opentofu` on macOS, [install docs](https://opentofu.org/docs/intro/install/) elsewhere. ## Apply `apply` itself finishes in a minute or two. The Hetzner server is up, but cloud-init is still bootstrapping the stack in the background. ## Wait for cloud-init Bootstrap (Docker install, monorepo clone, GHCR image pulls, first `compose up -d`) runs in the background after the server boots. A few minutes on first run. ## Verify ## Design choices Terraform is BUSL-licensed; OpenTofu is the MPL-licensed fork, drop-in compatible. Same `.tf` files work in both. Cloud-init installs Docker plus git and runs a single script committed in the repo. Stack-specific logic lives in readable bash: debuggable, testable, versioned. Hetzner is the cheapest production-viable VPS shop. The bootstrap module is provider-agnostic (cloud-init is universal), so replacing the VPS module is the only thing that changes for DigitalOcean, OVH, or Linode. `cx32`, `s-2vcpu-4gb`: the same names the provider docs, support, and billing page use. SSL strict, HSTS 6mo, TLS min 1.2, browser integrity on. Matches what `production-labels.yml` expects; each setting is one override away. One A/AAAA pair on the apex serves both the SPA and `/api/*` via same-origin path routing. `www.` is a CNAME to apex with a redirect rule. No `api.` subdomain; Traefik path-routes `/api/*` on the same host. Single-operator default; an S3 backend block is one paste away for teams. Same pragmatic floor as `compose/.env`; upgrade to a secret manager when team size demands it. Apply prints the IP, ssh command, and site URL. Never auto-opens anything. Same logic as the planned Kubernetes template: separation lets operators skip the tool entirely. ## What stays manual OpenTofu cannot paper over the things providers do not expose APIs for: You have to own it: registrar transfer or NS change. Billing decision; no API to flip the switch. No provider APIs for OAuth client registration. Stripe Terraform provider exists but is beta; most teams click through anyway. Beta product; some toggles are not in the Cloudflare provider yet. Each of these is one-time per project and documented in its own runbook (for example [Cloudflare Email setup](/runbooks/cloudflare-email-setup/)). Once you have the credentials, paste them into `terraform.tfvars` and `tofu apply` again. Cloud-init re-renders `compose/.env` and restarts the API. ## `terraform.tfvars` shape One file with every knob: ```hcl # Required hetzner_api_token = "..." cloudflare_api_token = "..." cloudflare_zone_id = "..." domain = "boringstack.example" # VPS sizing, Hetzner-native names vps_type = "cx32" # 4 vCPU / 8 GB vps_location = "fsn1" # Stack secrets jwt_secret = "..." # 32+ chars postgres_password = "..." valkey_password = "..." acme_email = "ops@example.com" # Optional integrations, leave empty to skip email_provider = "cloudflare" cloudflare_email_api_token = "" google_oauth_client_id = "" stripe_secret_key = "" # ... etc ``` Everything in `terraform.tfvars.example` ships with comments explaining what it's for and which features it enables. ## Repo layout Top-level composition: wires modules to variables, declares outputs. Input variable declarations with type and description. VPS IP, DNS records, ready-to-paste `ssh` command, site URL. All knobs with comments; copy to `terraform.tfvars` and fill in. VPS, SSH key, firewall, cloud-init injection. DNS records, opinionated zone settings, redirect rules. Cloud-init template that installs Docker plus git, then runs `bootstrap.sh`. Versioned shell script: clones the monorepo, renders `compose/.env`, runs `compose pull && compose up -d`. ## State management For a single operator: state file is local, gitignored. Default config. For a team: point the OpenTofu backend at S3 (or any S3-compatible store: Cloudflare R2, Backblaze B2, Hetzner Object Storage). One block in `main.tf`: ```hcl terraform { backend "s3" { bucket = "boringstack-tofu-state" key = "boringstack/terraform.tfstate" region = "..." } } ``` The state file holds secrets (cloud-init renders with sensitive values). Encrypt at rest; restrict bucket access. Same posture as everywhere else in BoringStack. ## Updating OpenTofu owns the infrastructure. GHCR + the monorepo own the running code. Code updates land via the release workflows. Push to `main` on `apps/api` or `apps/ui`, a new image tag appears on GHCR, and WUD on the VPS auto-deploys app containers. Base-image updates remain manual, applied on the VPS when you are ready: ```bash ssh root@$(tofu output -raw vps_ipv4) cd /opt/boringstack/infra docker compose pull docker compose up -d ``` Infra YAML or env-var changes: `git pull` the monorepo on the VPS, then re-run `compose up -d`. Infrastructure changes (VPS resize, DNS, firewall rule): edit `terraform.tfvars` or the modules, then `tofu apply`. ## Scaling up When single-host stops being enough, the upgrade path stays inside OpenTofu without rewrites: Yes: bump `vps_type`, apply, cloud-init re-runs. Yes: drop the Postgres service from compose, add the managed-DB module. Yes: adds a `modules/loadbalancer/` and parameterizes VPS count. No: that is when the planned Kubernetes template earns its place. The progression: vertical, managed data, horizontal stateless, cluster. Each step is additive, not a rewrite. ## Swapping the cloud provider The `bootstrap` module talks to cloud-init, which every major cloud accepts. Swapping Hetzner for DigitalOcean / OVH / Linode means replacing `module "vps"` in `main.tf` with the matching module; the rest of the graph (Cloudflare, bootstrap, outputs) doesn't change. Per-provider modules ship as they prove themselves. ## Destroying ```bash tofu destroy ``` Wipes the Hetzner server, removes the Cloudflare records, deletes the firewall and SSH key. Cloudflare zone settings revert to defaults. The state file remains; `rm terraform.tfstate*` for full cleanup. ## Troubleshooting Hetzner API status plus token scope (must be Read and Write). Token scope (`Zone:DNS:Edit` etc.) plus zone ID matches the domain. `ssh ... 'cloud-init status'`: bootstrap may still be running. Origin not responding: check `docker compose logs traefik api` on the server. TLS handshake failed; ACME has not issued yet: wait or check Traefik logs. Stale OpenTofu language-server cache. Run `tofu init` once and re-open. `rclone config` on the server: the cron entry references a remote that must be configured. ## When to skip OpenTofu - You like the SSH-and-edit flow and don't see the win. - You're already on a different IaC tool (Pulumi, AWS CDK, Crossplane). - You're deploying to a managed platform (Vercel, Render, Fly) that handles provisioning itself. The runtime repos work fine without this one. It's a convenience layer, not a dependency. ## Related - [Deployment](/topics/deployment/): the manual path this automates. - [Firewall & TLS](/runbooks/firewall-and-tls/): handled by the Hetzner module's firewall rules. - [Backups](/runbooks/backups/): cron plus rclone, baked into `bootstrap.sh`. - [Env backup and secrets](/runbooks/env-backup-and-secrets/): password-manager backup for `compose/.env` after provisioning. - [OAuth provider setup](/runbooks/oauth-provider-setup/): Google, GitHub, LinkedIn console walkthroughs. - [Cloudflare Email setup](/runbooks/cloudflare-email-setup/): the bit that stays manual after `apply`. # Security pipeline > BoringStack ships a layered security pipeline (CI gates, agent skills, repo-settings drift detection, project-aware review skill) running on every PR and on a weekly cron. import PageIntro from "../../../components/docs-kit/PageIntro"; import SignalGrid from "../../../components/docs-kit/SignalGrid"; import DocCallout from "../../../components/DocCallout.tsx"; import FaqGroup from "../../../components/FaqGroup.tsx"; import FaqItem from "../../../components/FaqItem.tsx"; A fresh fork ships with secret scanning, dep auditing, SAST, signed commits, branch protection, and agent skills already wired. Three automated layers block PRs on leaked secrets, vulnerable dependencies, and code patterns linked to auth bypass. All three run on a Monday-morning cron (06:23 UTC). New CVEs against existing dependencies are caught automatically; no manual rescans needed. ## Layer 1: CI gates Every push to `main` and every pull request runs three blocking workflows. ### `security-secrets` (gitleaks) Catches API keys, tokens, private keys, and other high-entropy strings before they reach `main`. GitHub's secret push protection is the first net. The gitleaks CLI with a versioned `.gitleaksignore` is the second. Findings upload as SARIF to the repo's Security tab. ### `security-deps` (osv-scanner + native audit) Two passes: - `osv-scanner` reads the lockfile and queries the OSV database for known CVEs across the dep tree, including transitive deps. - The native audit (`bun audit` for apps/api, `bun run audit` for apps/ui, Trivy `config` mode for the OpenTofu repo) catches things the OSV cross-reference misses. Both honor `osv-scanner.toml` for accepted-risk allowlisting. Every ignored CVE carries a reason and an `ignoreUntil` date. When the date passes, the suppression dies and CI fails. No silent suppressions, no infinite snoozing. ### `security-sast` (Semgrep) Runs OWASP and JavaScript rule packs plus repo-specific rules from `.semgrep/`. Findings upload as SARIF to GitHub Code Scanning. The custom rules catch BoringStack-specific footguns: `new Function`-style template eval, logger payloads that include PII, raw SQL string concatenation. ### Repo settings hardening The monorepo root `./scripts/audit-repo-settings.sh` diffs the live GitHub configuration against `.github/desired-repo-settings.json`. Drift prints copy-pasteable `gh api` commands. Nothing auto-applies. The desired state on every repo: - Secret scanning and push protection: enabled - Dependabot security updates: enabled - Merge style: squash-only, auto-delete branch - `main` branch protection: signed commits required, linear history, no force-push, no deletion, all status checks blocking, conversations must resolve ### Weekly cron All security workflows fire on `0 6 * * 1` (Monday morning UTC, staggered by minute to avoid the GitHub Actions cron pileup at `:00`). So even if nobody pushes for a month, CI catches: - A new CVE filed against a dep you're already using - An `ignoreUntil` allowlist entry expiring - A new rule release from Semgrep or osv ## Layer 2: agent skills Two marketplaces are declared in `.claude/settings.json`. When you trust the folder, Claude Code prompts to install them. Trail of Bits ships six specialist skills the same firm uses on paid engagements: | Skill | Use it when | | --- | --- | | `/differential-review` | Reviewing a diff for security regressions | | `/sharp-edges ` | Asking "what could bite me in this file?" | | `/supply-chain-risk-auditor` | Adding a new dep | | `/insecure-defaults` | Reviewing config and env handling | | `/static-analysis` | Running ad-hoc CodeQL/Semgrep on a branch | | `/fp-check` | Getting a second opinion on a finding | Ghost Security adds two AI-driven scanners: | Skill | Use it when | | --- | --- | | `/ghost-scan-code` | Want a SAST sweep over a diff | | `/ghost-validate` | Probing a running service for live vulnerabilities (DAST) | Install once. After that, humans and agents can both invoke `/sharp-edges src/auth/oauth.service.ts` and get a deep pass without leaving the editor. ## Layer 3: project-owned review skill `.claude/skills/security-review.md` in each template orchestrates Layer 2 and adds checks the generic tools can't make. For apps/api: - ACL coverage on every account-scoped table - Stripe webhook idempotency (`stripe_event_id` dedup) - Multi-tenant `accountId` scoping on every route handler - Rate limits on credential routes (`/auth/login`, `/auth/forgot-password`, `/auth/resend-verification`) - Audit-log on every mutation - BullMQ jobs idempotent under retry For apps/ui: - No raw `fetch`; only `@/lib/api/client.ts` calls the API - No `dangerouslySetInnerHTML` - No `import.meta.env` outside `src/lib/env/` - No localStorage token storage - CSRF and content-type validation on user-upload flows Invoke either way: ``` /security-review ``` ## Allowlist hygiene Every accepted-risk suppression has a date and a reason. The format is consistent across the three layers. `osv-scanner.toml` holds accepted CVEs: ```toml [[IgnoredVulns]] id = "GHSA-67mh-4wv8-2f99" ignoreUntil = "2026-11-18T00:00:00Z" reason = """ esbuild dev-server RCE. Production builds (Dockerfile.prod) do not run the esbuild dev server; the bundled artifact has no exposed surface. Awaiting upstream patch via vite transitive deps. """ ``` `.gitleaksignore` holds known false positives (test fixtures, public keys): ``` ::: ``` `// nosemgrep: ` is the inline Semgrep suppression. Each one needs a sibling block comment explaining why: ```ts /* * `precompiledCode` is the JSON output of Handlebars.precompile() over * template files we own. Never user input, never network-reachable. */ // nosemgrep: semgrep.no-eval const spec: unknown = new Function("return " + precompiledCode)(); ``` When the `ignoreUntil` passes, CI fails on the next run. The discipline is **"every suppression is temporary by default."** There is no `ignoreForever`. If an entry keeps getting extended, fix the rule (false positive) or fix the code (real risk). ## What this doesn't replace This pipeline is opinionated for the BoringStack template surface. It doesn't replace: - Penetration testing before a production launch - Threat modeling for novel surface area you add on top - Compliance audits (SOC 2, ISO 27001), which need an auditor, not a CI workflow - Manual review of cryptography, secrets storage, or session handling you write yourself The CI gates block known-bad patterns. The agent skills surface "you forgot to think about X." Neither substitutes for thinking. ## When CI fails First check whether it's a real secret. If yes, rotate it immediately (the secret is already in git history) and amend the commit. If false positive (test fixture, public key), add a fingerprint line to `.gitleaksignore` and re-push. Read the advisory. If patched, bump the dep and re-run. If unpatched but not reachable from your code path, add an `[[IgnoredVulns]]` block to `osv-scanner.toml` with a written reason and an `ignoreUntil` date one quarter out, giving upstream time to ship a patch. Add `// nosemgrep: ` directly above the line, plus a block comment explaining why the pattern is safe in context. If the rule fires this way often, propose tightening the rule in `.semgrep/` instead. A new CVE was filed against an existing dep, or an `ignoreUntil` expired. Read the run output, triage as above. The cron exists for this. It surfaces drift in your dependency surface even when you're not actively pushing. Someone (or you) clicked a setting in the GitHub UI. Paste the suggested `gh api` commands and re-run the audit. If the desired state is wrong, update `.github/desired-repo-settings.json` first. ## References - [Trail of Bits skills marketplace](https://github.com/trailofbits/skills) - [Ghost Security skills marketplace](https://github.com/ghostsecurity/skills) - [Supply-chain protection](/topics/supply-chain/), the 7-day minimum release age that sits underneath the rest - [gitleaks](https://github.com/gitleaks/gitleaks) - [osv-scanner](https://github.com/google/osv-scanner) - [Semgrep](https://semgrep.dev/) # Supply-chain protection > Every BoringStack repo refuses to install npm package versions younger than seven days. A short delay against rug-pull and account-hijack attacks on fresh publishes. import PageIntro from "../../../components/docs-kit/PageIntro"; import SignalGrid from "../../../components/docs-kit/SignalGrid"; import DocCallout from "../../../components/DocCallout.tsx"; import FaqGroup from "../../../components/FaqGroup.tsx"; import FaqItem from "../../../components/FaqItem.tsx"; Supply-chain attacks follow a predictable timeline: a maintainer's account is compromised, a malicious version is published, the community detects and yanks it within hours to days. The undetected window is short but exploitable. Every BoringStack repo enforces a **seven-day minimum release age** to block installs inside that window. ## How it works per package manager `bunfig.toml`: `[install] minimumReleaseAge = 604800` (seconds). `bunfig.toml`: `[install] minimumReleaseAge = 604800` (seconds), with per-package excludes for high-churn and first-party packages. `bunfig.toml`: `[install] minimumReleaseAge = 604800` (seconds). No JS deps; n/a. No JS deps; n/a. ## Trade-offs Pin to a specific older version or wait. Override per-package via Bun/npmrc exclusions when a real CVE drops. This is the point. ## Overriding for a specific package Bun supports per-package overrides in `bunfig.toml` via `minimumReleaseAgeExcludes`: ```toml minimumReleaseAgeExcludes = [ "yaml", "@tailwindcss/oxide*", ] ``` Glob patterns are supported, useful for platform-variant packages like `@tailwindcss/oxide-*`. Each exclusion weakens the threat model slightly. The default `apps/ui/bunfig.toml` already excludes several high-churn packages whose latest versions fall within the seven-day window. Only add exclusions when a specific CVE or release justifies it. Document the reason in a comment. ## Why not just trust npm? npm's audit signal lags detection. By the time `npm audit` reports a malicious version, the attack window has closed. This rule does not replace audits. It is a low-cost, independent layer that extends the detection window for those tools. ## References - [Bun docs: install lockfile settings](https://bun.com/docs/install/lockfile) ## Related - [Security pipeline](/topics/security/); CI scans, SAST, and dependency review that pair with the install delay. - [Lint as the contract](/architecture/lint-as-contract/); architecture-level guardrails the merge gate enforces. - [Commands cheatsheet](/reference/commands/); where `bun run validate` and `bun run validate` live. # Architecture rules > The UI-specific lint rules that hold the component anatomy in place: hook placement, className discipline, file-naming, prop ordering. import PageIntro from "../../../components/docs-kit/PageIntro"; import FeatureGrid from "../../../components/docs-kit/FeatureGrid"; import FaqGroup from "../../../components/FaqGroup.tsx"; import FaqItem from "../../../components/FaqItem.tsx"; The apps/ui enforces its architecture through ESLint, not vibes. The UI-specific work is [`@boring-stack-pkg/eslint-plugin-react-component-architecture`](https://www.npmjs.com/package/@boring-stack-pkg/eslint-plugin-react-component-architecture). It composes with the shared plugins (resource-architecture, module-boundaries, structured-logging, env-access, test-conventions, code-flow) that both apps use. ## What it enforces No hooks in `.tsx`; logic belongs in `.hooks.ts`. Constants, utils, and types each get their own file (`.constants.ts`, `.utils.ts`, `.types.ts`). Use `cn(...)`; pull long class strings out for readable conditionals. PascalCase components; kebab-case otherwise; suffix matches role (`.hooks.ts`, `.queries.ts`, `.store.ts`). Required props, optional props, then handlers. Components read the hook's view object; no direct TanStack Query, Zustand, or `import.meta.env`. `useQuery` only in `*.queries.ts`; component hooks call those query hooks. Each rule has a fix-it suggestion where mechanical; the rest fail the lint gate and need a real edit. ## Component anatomy benefits ## Suppressing a rule `// eslint-disable-next-line ` works, but every suppression is reviewed. Common valid cases: - Third-party render-prop APIs that force a hook-like pattern in `.tsx`. - Sub-components that have no state but are too small to split into their own folder. Inline them; the lint rule has a size threshold. If you find yourself suppressing the same rule across many files, that's a signal to update the rule, not to keep suppressing. ## Source Plugin source + per-rule docs: [`@boring-stack-pkg/eslint-plugin-react-component-architecture`](https://www.npmjs.com/package/@boring-stack-pkg/eslint-plugin-react-component-architecture). apps/ui ESLint config: [`eslint.config.mjs`](https://github.com/boringstack-xyz/boringstack/blob/main/apps/ui/eslint.config.mjs). ## Related - [Lint as the contract](/architecture/lint-as-contract/): the full family across apps/api and apps/ui. - [lint:meta rules](/architecture/lint-meta/): static guardrails under `scripts/lint-meta/`. - [Scripts & tooling](/reference/scripts-tooling/): command → script map for apps/ui. - [UI template overview](/ui/overview/): the component anatomy these rules enforce. # i18n > react-i18next with type-safe keys, a single namespace by default, and a lint rule that bans hardcoded JSX strings. import PageIntro from "../../../components/docs-kit/PageIntro"; import DocCallout from "../../../components/DocCallout.tsx"; import FaqGroup from "../../../components/FaqGroup.tsx"; import FaqItem from "../../../components/FaqItem.tsx"; The apps/ui uses [react-i18next](https://react.i18next.com) for translation. Keys are type-safe (typos fail the build) and hardcoded strings in JSX are a lint error, so a forgotten translation can't ship. ## Design choices Keep it simple; split into more namespaces only when one balloons past ~200 keys. Friendly default for international visitors; predictable for tests. Two locales prove the pipeline; pick any two you actually need and replace. Trivial to diff, translate, and review. Avoids a flash of fallback UI during i18n init. Forgotten translations cannot ship; the linter catches the JSX literal. ## How it's wired ```mermaid flowchart LR detect["LanguageDetector
browser language"] catalogs["src/lib/i18n/locales/
en/common.json · de/common.json"] i18n["i18next + react-i18next"] hook["useTranslation('common')
const { t } = ..."] component["component renders
{t('auth.signIn')}"] detect --> i18n catalogs --> i18n i18n --> hook hook --> component ```

Translation flow: the LanguageDetector reads the browser's preferred language; JSON catalogs under src/lib/i18n/locales/ feed i18next; the{" "} useTranslation('common') hook returns t; components call t('key') with statically-checked keys.

## Using it ```tsx import { useTranslation } from "react-i18next"; const SignInButton = () => { const { t } = useTranslation(); return ; }; ``` A missing key in any locale that's listed in `VITE_LOCALES` is a build-time concern, not a runtime one; the type generation step would flag a key that exists in `en` but not in `de` (or vice versa). ## Adding a locale 1. Drop `/common.json` under `src/lib/i18n/locales//`. 2. Add `` to `VITE_LOCALES` (comma-separated). 3. Update the import in `src/lib/i18n/config.ts` to register the catalog. The language detector picks it up automatically; users on browsers in that locale start seeing it. ## Adding a key 1. Add `"my.new.key": "English copy"` to `en/common.json`. 2. Add the translation to every other locale's `common.json` (an `_TODO_` placeholder works as a build-tolerant intermediate). 3. Use it: `t("my.new.key")`. For interpolation, [react-i18next docs](https://react.i18next.com/guides/quick-start#using-with-react) cover the syntax. For pluralization, the `_one` / `_other` suffix convention. ## Patterns to avoid - **Fragment concatenation:** `t("the") + " " + t("button")`. Word order is language-specific; build full sentences as keys. - **Sentence-as-ID:** `t("Click here to continue")`. Hard to refactor when copy changes; use semantic keys like `t("checkout.continue")`. - **Hardcoded `aria-label` / `placeholder`:** these are content, not technical strings. The lint rule applies. ## Lint coverage The UI app's lint config bans hardcoded JSX strings on user-facing text. Numbers, technical identifiers, and `data-*` attributes are exempt. Static `t("…")` keys are also checked against the English catalog by [`@boring-stack-pkg/eslint-plugin-i18n-keys`](https://www.npmjs.com/package/@boring-stack-pkg/eslint-plugin-i18n-keys) so a typo cannot ship. See [Architecture rules](/ui/architecture-rules/) and [Lint as the contract](/architecture/lint-as-contract/). ## Source [`src/lib/i18n/`](https://github.com/boringstack-xyz/boringstack/tree/main/apps/ui/src/lib/i18n); config, locales, and the catalog JSON. ## Related - [Architecture rules](/ui/architecture-rules/); the component anatomy that hosts `t("…")` calls. - [UI template overview](/ui/overview/); where the i18n layer sits in the SPA shell. - [Lint as the contract](/architecture/lint-as-contract/); the `eslint-plugin-i18n-keys` rule that catches typos at build. - [Testing](/ui/testing/); how view-object tests stay locale-stable. # Notifications > TanStack Query feed, infinite list, optimistic mark-read, SSE consumer, Sonner toast on arrival, and a per-event-type × channel preferences grid. Backend ships pre-rendered strings so the UI is event-agnostic. import PageIntro from "../../../components/docs-kit/PageIntro"; import DocFileTree from "../../../components/DocFileTree"; import FaqGroup from "../../../components/FaqGroup.tsx"; import FaqItem from "../../../components/FaqItem.tsx"; The UI consumes the [API notifications subsystem](/api/notifications/). The feed is a TanStack Query infinite list, mutations are optimistic with rollback, and an optional SSE `EventSource` keeps the cache live while the user is authenticated. The bell + popover mount inside `AppShell`, so every authenticated route gets them for free. The backend ships pre-rendered `{ title, body, ctaUrl, ctaLabel }` strings. The UI renders them as plain content, no per-event-type switches. ## Flow ```mermaid sequenceDiagram participant API participant SSE as EventSource (Browser) participant Hook as useNotificationStream participant Cache as TanStack Query cache participant Bell as NotificationBell participant Toast as Sonner Note over Hook,SSE: AppShell mounts the hook for every authenticated route Hook->>SSE: open /api/v1/notifications/stream API-->>SSE: PUBLISH notifications:user: SSE-->>Hook: message event Hook->>Hook: parseStreamMessage (defensive) Hook->>Cache: prepend to list page · bump unreadCount Cache-->>Bell: badge re-renders Hook->>Toast: title + body + optional CTA ``` ## Folder shape Same anatomy as `features/dashboard/` (queries + utils at the feature root, components in `components//` with the 8-file layout). The query surface is split across three files (reads, mutations, preferences) to keep each file under the `max-hooks-per-file` threshold enforced by [`@boring-stack-pkg/eslint-plugin-react-component-architecture`](https://www.npmjs.com/package/@boring-stack-pkg/eslint-plugin-react-component-architecture). ## Queries The full hook surface, grouped by file: ```ts // Notifications.list.queries.ts useNotificationsList(status?: "unread" | "read" | "archived") useUnreadNotificationCount() // Notifications.mutations.ts useMarkNotificationRead() // optimistic useArchiveNotification() // optimistic useMarkAllNotificationsRead() // optimistic // Notifications.preferences.queries.ts useNotificationPreferences() useUpdateNotificationPreferences() ``` `useUnreadNotificationCount` reads from the list cache when present and falls back to a server query otherwise. Every mutation snapshots the cache, applies the optimistic write via the helpers in `Notifications.cache.ts`, and rolls back if the request fails. `onSettled` invalidates both list and unread-count keys so the UI reconciles with the server. ## Realtime `useNotificationStream` is mounted once in `AppShell.hooks.ts`. It opens a credentialed `EventSource` against `${VITE_API_URL}/api/v1/notifications/stream` only when `capabilities.features.notifications.sse === true` (from `GET /api/v1/capabilities/`). When SSE is disabled on the API (`NOTIFICATIONS_SSE_ENABLED=false`, the default), notifications still work through the paginated feed and mutations — just without live push. ```ts mergeStreamNotificationIntoCache(qc, notification); toast(notification.title, { description: notification.body, action: notification.ctaUrl !== null ? { label: notification.ctaLabel ?? t("notifications.openCta"), onClick: () => { void navigate(notification.ctaUrl); } } : undefined }); ``` Defensive parse: malformed JSON or messages with the wrong shape are dropped with a warn log, never thrown. ## Routes | Path | Component | | --- | --- | | `/notifications` | `NotificationsPage` | | `/notifications/preferences` | `NotificationsPreferencesPage` | Both wrap inside ``, which holds the header, bell, logout, and the SSE hook. ## Adding a new notification UI You don't. The backend ships pre-rendered strings; the UI is event-agnostic by design. To add a new event type, the API defines it (see [API notifications](/api/notifications/)) and the bell, page, and toast pick it up with no UI change. If you ever need a per-event-type visual treatment (badge colour, icon), branch on `notification.eventType` inside `NotificationListItem` only. Don't fork the page. ## Web Push (v1.1) Browser push notifications via the W3C Push API + VAPID. The whole flow lives in `useWebPush.hooks.ts` (under `src/hooks/`) plus a small service worker at `public/sw.js`. The Settings page renders a state-aware "Browser notifications" card that wraps the hook. Generate VAPID keys on the API (`bun run vapid:generate`) and paste the public key into the UI as `VITE_VAPID_PUBLIC_KEY`. Without it, the Settings card renders "Web Push is not configured for this deploy." `public/sw.js` is copied to the dist root by Vite (no plugin needed) so the scope is `/`. Two handlers: `push` calls `showNotification(title, { body, data: { url } })`; `notificationclick` focuses the matching tab if open, otherwise opens the URL. Registered once from `src/app/main.tsx` (gated on `'serviceWorker' in navigator`). Returns `{ isSupported, isConfigured, permission, isSubscribed, isPending, subscribe, unsubscribe }`. The Settings card maps that state machine into copy: unsupported / not-configured / blocked / not-subscribed / subscribed. Lives in `src/hooks/` because the feature `accounts` consumes it without owning it. `web-push` appears alongside `in-app` and `email` in `PREFERENCE_CHANNEL_COLUMNS`. Toggling it follows the same pattern as other channels: the backend dispatcher reads `notification_preference` rows for `(userId, eventType, channel)`. ## Out of scope (v1) Each tab holds its own SSE connection; the badge converges via cache invalidation. Backend doesn't roll up "3 people liked your post" yet; UI doesn't either. Backend ships pre-rendered strings; UI stays event-agnostic. ## Related - [API notifications](/api/notifications/): the dispatcher, channels, and SSE source. - [OpenAPI client](/ui/openapi-client/): how the typed client surfaces the notifications endpoints. - [Component anatomy](/ui/architecture-rules/): the 8-file layout these components follow. # OpenAPI client > A typed API client generated from the API app's OpenAPI schema. Drift between server and UI becomes a compile error. import PageIntro from "../../../components/docs-kit/PageIntro"; import DocCallout from "../../../components/DocCallout.tsx"; import FaqGroup from "../../../components/FaqGroup.tsx"; import FaqItem from "../../../components/FaqItem.tsx"; The UI never hand-writes `fetch(...)`. It calls a generated, typed client that knows every path, every request body, and every response shape the API exposes. When the API changes, you regenerate; if the UI now calls a path that no longer exists, TypeScript tells you before the user does. ## How it stays in sync ```mermaid flowchart LR api["apps/api
publishes /swagger/json"] gen["bun run generate:api"] schema["src/lib/api/schema.d.ts
generated types"] client["apiClient.GET / POST / ..."] api --> gen gen --> schema schema --> client client -.->|HTTP| api ``` `bun run generate:api` runs `openapi-typescript` against the live API (or a saved spec) and emits one big `.d.ts` of paths, params, and response components. `openapi-fetch` wraps native `fetch` and uses those types so every call is path- and shape-checked. ## Design choices Drift between server and client is a compile error, not a runtime 500. Single place for base URL, cookie credentials, error mapping, refresh logic. TanStack Query `error` is typed and structured; no string parsing. Parallel queries do not trigger N refresh storms. No infinite loops when the refresh itself fails. ## Using it ```ts import { apiClient } from "@/lib/api/client"; const { data } = await apiClient.GET("/api/v1/users/me"); // ^? typed exactly as the API's response shape ``` A path that doesn't exist in the schema is a compile error. A body that doesn't match is a compile error. `data` is fully typed. Inside TanStack Query: ```ts useQuery({ queryKey: ["users", "me"], queryFn: async () => { const { data, error } = await apiClient.GET("/api/v1/users/me"); if (error) throw new ApiError(error); return data; }, }); ``` ## The middleware layer `openapi-fetch` is configured with `credentials: "include"`, so the browser sends `auth_token` and `refresh_token` cookies automatically. The UI never reads a JWT, stores a bearer token, or adds an `Authorization` header. `openapi-fetch` accepts middleware. The template ships one: on a 401, kick off a single `/auth/refresh` (with a module-level promise guarding against parallel triggers), then retry the original request. Refresh exempts itself + `/auth/login` so a failed refresh never recurses. If the refresh fails, the original 401 propagates and `ProtectedRoute` redirects to `/login`. Direct `fetch()`, `axios`, and `XMLHttpRequest` outside `src/lib/api/` fail the lint gate because re-implementing 401 refresh in twenty places is how token-handling bugs ship. ## Regenerating Run `bun run generate:api` against the running dev API. Point `generate:api` at a saved `.json`. Run `bun run generate:api && git diff --exit-code src/lib/api/schema.d.ts`. The CI check fails if a developer changed the API but forgot to regenerate; drift gets caught at PR time, not at runtime. ## Adding a call There's no "adding". If the API exposes a new endpoint, `bun run generate:api` makes it available; you call it the same way you call any other. ## Lint coverage Direct `fetch()` / `axios` / `XMLHttpRequest` outside `src/lib/api/` fails the lint gate. See [Lint as the contract](/architecture/lint-as-contract/). ## Source [`src/lib/api/`](https://github.com/boringstack-xyz/boringstack/tree/main/apps/ui/src/lib/api) on GitHub; client, middleware, error mapper, generated schema. ## Related - [API template overview](/api/overview/); where the OpenAPI spec comes from. - [Architecture rules](/ui/architecture-rules/); the component layer that consumes the client via queries. # UI template: overview > Vite + React 19 + TanStack Query + shadcn/ui + Zustand. A typed OpenAPI client and an ESLint-enforced component anatomy. import DocFileTree from "../../../components/DocFileTree"; import DataMatrix from "../../../components/docs-kit/DataMatrix"; import FeatureGrid from "../../../components/docs-kit/FeatureGrid"; import PageIntro from "../../../components/docs-kit/PageIntro"; import SignalGrid from "../../../components/docs-kit/SignalGrid"; The UI is a Vite + React SPA with a typed OpenAPI client: fast local feedback and a compile-time contract with the API. Architecture rules keep [feature folders](/reference/glossary#feature-folder) small enough for humans and agents to change safely. A production-shaped SPA. Architecture rules (component anatomy, queries vs stores, OpenAPI client) keep features from turning into 600-line `.tsx` blobs as the codebase grows. ## How a feature is shaped ```mermaid flowchart LR page["MyPage.tsx
pure JSX, no state"] hook["MyPage.hooks.ts
useState · useEffect · useCallback"] types["MyPage.types.ts
IMyPageView (hook's return shape)"] query["my-feature.queries.ts
TanStack Query"] store["my-feature.store.ts
Zustand (UI state)"] page --> hook hook --> query hook --> store hook -.->|returns| types page -.->|reads| types ```

Each UI feature folder splits into role-specific files: a pure-JSX{" "} .tsx renders what its hook returns; a .hooks.ts owns all React hooks plus the calls into TanStack Query and Zustand; a{" "} .types.ts declares the view-object shape the component reads. Components never touch queries, stores, or env directly.

Components only ever see the [**view object**](/reference/glossary#view-object) from their hook. They never read TanStack Query directly, never read Zustand directly, never read `import.meta.env` directly. That's what makes any component trivially testable. ## Design choices ## Routes & shell Every authenticated route renders inside `AppShell`: a brand-marked left sidebar (`AppSidebar` with `NavLink` + `aria-[current=page]:` Tailwind active styling), a sticky header (account switcher · notification bell · theme toggle · logout), and the page content. On mobile the sidebar collapses into a `Sheet` drawer triggered from the header. `SettingsPage` ships with an explicit "placeholder, fill this in" copy block so a fork knows the page is wired into the nav but the form is yours to write. ## File layout A page or component folder always looks like: Stories ship 1:1 with the components and run under a global theme decorator (`@storybook/addon-themes` wired in `.storybook/preview.tsx`), so every story has a light/dark toggle in the Storybook toolbar with no per-story plumbing. `bun run new:component ` writes this anatomy. `bun run new:feature ` writes a feature scaffold. ## State, in one decision If you can't tell which bucket something belongs to, that's almost always a sign the boundary is wrong; not a need for a fifth bucket. ## The typed OpenAPI client The API publishes `/swagger/json`. `bun run generate:api` reads it and emits the typed client. From there `apiClient.GET("/api/v1/users/me")` autocompletes the path and types the response. Drift between server and client becomes a compile error, not a runtime 500. See [OpenAPI client](/ui/openapi-client/). ## Testing See [Testing](/ui/testing/). ## Lint as the contract The component anatomy is held in place by [`@boring-stack-pkg/eslint-plugin-react-component-architecture`](https://www.npmjs.com/package/@boring-stack-pkg/eslint-plugin-react-component-architecture). TanStack Query cache consistency on `*.queries.ts` is enforced by [`@boring-stack-pkg/eslint-plugin-tanstack-query-cache`](https://www.npmjs.com/package/@boring-stack-pkg/eslint-plugin-tanstack-query-cache); static translation keys by [`@boring-stack-pkg/eslint-plugin-i18n-keys`](https://www.npmjs.com/package/@boring-stack-pkg/eslint-plugin-i18n-keys). Those sit alongside the shared plugin family. See [Lint as the contract](/architecture/lint-as-contract/) for the full inventory. ## Source [`apps/ui`](https://github.com/boringstack-xyz/boringstack/tree/main/apps/ui) on GitHub. Start in `src/features/` for the feature shape; `src/lib/api/` for the typed client. ## Related - [Architecture rules](/ui/architecture-rules/); the component anatomy the lint enforces. - [OpenAPI client](/ui/openapi-client/); how the React app stays in sync with the API. - [Testing](/ui/testing/); the three test layers and why there's no mock layer. - [i18n](/ui/i18n/); type-safe translation keys with linted JSX strings. - [Notifications](/ui/notifications/); the in-app surface for system and user events. # Testing > Vitest for units, Testing Library for components, Playwright for end-to-end (Chromium + WebKit) with visual baselines. No MSW. import PageIntro from "../../../components/docs-kit/PageIntro"; import SignalGrid from "../../../components/docs-kit/SignalGrid"; import FaqGroup from "../../../components/FaqGroup.tsx"; import FaqItem from "../../../components/FaqItem.tsx"; Three test layers, each earning its keep: Vitest for pure logic, Testing Library for component rendering, Playwright for end-to-end against the real Compose stack. No mock service workers. Anything HTTP-shaped is e2e. ```mermaid flowchart LR unit["Unit
Vitest
no backend"] component["Component
Testing Library
no backend"] e2e["e2e
Playwright
real stack"] visual["Visual
Playwright
per-platform"] unit --> component --> e2e --> visual ```

Four test layers in order of cost and scope: Vitest units (no backend) feed into Testing Library component tests (no backend), which give way to Playwright end-to-end against the real Compose stack, and finally per-platform Playwright visual snapshots.

## Design choices Safari behavior bugs surface in CI, not from a user report. macOS vs Linux font rendering differs; baselines committed per OS. Numbers only count files you can meaningfully test. ## What lives where Location: `src/**/*.test.ts` colocated with source. Run: `bun run test`. Location: `e2e/*.spec.ts`. Run: `bun run e2e` (needs dev stack up). Location: `e2e/visual.spec.ts-snapshots/`. Run: `bun run e2e:visual:update` to refresh. Run via `bun run test:ci`. ## Why no mock layer Unit and component tests focus on pure logic; anything HTTP-shaped is e2e against the real backend. Three reasons: - Drift. Hand-written mocks fall behind the real API shape, and tests pass while the real backend drifts. - Mental tax. Contributors would have to learn the mocking framework and the real API. - False signal. "Mock tests are green" isn't "the feature works." Only the e2e tier proves the feature. ## Patterns Component test: render the component, assert on the rendered output. Don't reach into hook internals; hooks have their own test if they're complex enough to need one. Hook test: `renderHook` from Testing Library; assert on the returned [view object](/reference/glossary#view-object) (the `IXxxView` shape). Standard pattern for testing a component's logic without rendering the UI. E2E test: navigate, interact, assert. Use Playwright's page-object pattern under `e2e/pages/` for anything reused across specs. Baseline visual diffs live in `e2e/visual.spec.ts-snapshots/`. ## When tests break in CI but pass locally Usually one of three things: - Visual baselines: different OS font rendering. The CI workflow stores baselines per-platform; run `bun run e2e:visual:update` on a matching machine, or regenerate baselines in CI itself. - Flaky timing: Playwright auto-waits, but custom polling loops in app code can race. Look for `setTimeout`-based assumptions. - API drift: backend changed, `bun run generate:api` wasn't run. CI catches this via the schema diff check. ## Lint coverage [`@boring-stack-pkg/eslint-plugin-test-conventions`](https://www.npmjs.com/package/@boring-stack-pkg/eslint-plugin-test-conventions) enforces `tests/` mirrors `src/` and that every test file has a real source file behind it. No orphan tests, no source files without tests for the things that need them. ## Source [`vitest.config.ts`](https://github.com/boringstack-xyz/boringstack/blob/main/apps/ui/vitest.config.ts) · [`playwright.config.ts`](https://github.com/boringstack-xyz/boringstack/blob/main/apps/ui/playwright.config.ts) · [`e2e/`](https://github.com/boringstack-xyz/boringstack/tree/main/apps/ui/e2e) on GitHub. ## Related - [Architecture rules](/ui/architecture-rules/); the folder anatomy these tests shadow 1:1. - [UI template overview](/ui/overview/); the SPA shell the tests cover end-to-end. - [OpenAPI client](/ui/openapi-client/); the typed contract whose drift e2e catches. - [Lint as the contract](/architecture/lint-as-contract/); the test-conventions plugin enforcing no-orphan tests.