Building web applications since 2017 — started with PHP, HTML & CSS, via JavaScript, now almost exclusively TypeScript, Docker, type safety & Bun. Focus: end-to-end types (oRPC + Valibot), lean alpine/scratch images and fast Bun builds. The guide below is my 09/2026 blueprint — copy-paste ready, no product logic.

.md · 45.2 KB

Philipps 09/2026 Tech Stack Guide

Copy-paste blueprint for a Bun + SvelteKit + oRPC monorepo with end-to-end type safety, independent per-app releases, and Docker/Nginx deployment. No product-specific logic is documented here — only the structural pattern.


1. Overview

Layer Choice Why
Runtime Bun (ghcr.io/philippdormann/bun:1.4.2) Fast install/start, native Bun.serve, Bun.file/Bun.write — minimal + zero CVE
Package manager Bun workspaces (bun.lock at root) Single lockfile, workspace:* links, always version pinned deps
Language TypeScript (strict) strict: true, noUncheckedIndexedAccess, bundler resolution
Backend Bun.serve + oRPC v2 oRPC gives RPC + OpenAPI from one router
Validation Valibot (@orpc/valibot) Lightweight, converts to JSON Schema for OpenAPI
Frontend Svelte 5 + SvelteKit 3 + Vite 8 + Tailwind 4 + shadcn-svelte + cn Runes, adapter-static (SPA) or adapter-node (SSR) — UI via shadcn-svelte (Tailwind + Bits UI) where available, no custom primitives; class merging via cn (not clsx/tailwind-merge/cnfast)
Fonts Fontsource (@fontsource/* self-hosted) Privacy — no Google Fonts CDN (fonts.googleapis.com), self-hosted subsets/weights, CSP-friendly (fontsource.org)
DB ORM Drizzle ORM (drizzle-orm + drizzle-kit) on Postgres via Bun's built-in Bun.SQL (drizzle-orm/bun-sql, no extra driver) Typed SQL via Bun native driver — no pg / postgres / mysql2 / better-sqlite3 needed (Bun SQL docs) — Postgres is the preferred DB; MySQL/MariaDB is legacy/avoid for new code
Cache / Queues Valkey + BullMQ + croner Sessions, rate-limits, scheduled jobs
Object Storage Bun's built-in S3Client (bun — S3Client / s3 global, no extra SDK) S3-compatible (AWS / R2 / MinIO / Spaces) — no @aws-sdk/* needed (Bun S3 docs) — central new S3Client() per bucket
CDN / Assets Hono + Sharp (optional) Lightweight edge caching, image transforms
Observability Sentry (@sentry/bun / @sentry/browser + ORPCInstrumentation) Traces, error capture
CI/CD GitLab CI + Docker Buildx + Caddy audit stage 0 (bun audit gate) → tag-triggered per-app arm64 images
Security bun audit (stage audit, step 0) Blocking CI gate on every tagged build; fix before build starts

2. Directory Layout

.
├── package.json              # root workspaces definition
├── tsconfig.json             # shared base TS config
├── bun.lock
├── patches/                  # patchedDependencies (e.g. drizzle-kit)
├── Caddyfile                 # reverse proxy (prod)
├── docker-compose.yml        # local dev (valkey, mailpit, apps)
├── apps/*/Dockerfile         # file lives here: apps/backend/Dockerfile, apps/mobile/Dockerfile … (all minimal + zero-CVE based). Always build with repo-root context: `docker build -f apps/<app>/Dockerfile .`
├── .gitlab-ci.yml            # tag-triggered builds
├── scripts/                  # one-off codegen / maintenance scripts
│   └── update-*.ts
├── packages/
│   └── shared/               # @scope/shared — framework-agnostic utils + Svelte components
│       ├── package.json      # "exports" map, no bundling, direct .ts/.svelte imports
│       ├── *.ts
│       └── *.svelte
└── apps/
    ├── backend/              # Bun HTTP server, oRPC router, DB, queues
    │   ├── src/
    │   │   ├── server.ts     # Bun.serve entry (fetch + websocket) — sole listen point
    │   │   ├── index.ts      # type-only re-export of RouterType, no runtime
    │   │   ├── orpc/         # router, context, middleware, procedures/
    │   │   ├── db/           # drizzle clients + schema (generated)
    │   │   ├── middleware/   # cors, requestSize
    │   │   └── cron/         # scheduled jobs
    │   └── package.json      # @scope/backend, exports: { ".": { "types": "./src/index.ts" } } (types-only)
    ├── <frontend-a>/         # SvelteKit + adapter-static → nginx
    ├── <frontend-b>/         # SvelteKit + adapter-static → nginx
    ├── <frontend-c>/         # SvelteKit + adapter-static → nginx
    ├── website/              # SvelteKit + adapter-node  → node (SSR)
    └── cdn/                  # Hono server (Bun), optional

Rule: Anything imported by ≥2 apps lives in packages/shared. Anything app-specific stays in that app.


3. Package Manager & Workspaces

Root package.json:19-22:

{
  "private": true,
  "type": "module",
  "workspaces": ["packages/*", "apps/*"]
}
  • Use bun add -E <pkg> / bunx (never npx, never hand-edit dependencies).

  • Inter-package deps are workspace:*:

    { "dependencies": { "@scope/shared": "workspace:*" } }
    
  • Frontends depend on backend as devDependency only — they need its types, not its runtime:

    { "devDependencies": { "@scope/backend": "workspace:*" } }
    
  • Optional: patchedDependencies for upstream fixes without forking.

Root scripts delegate per-app:

{
  "scripts": {
    "check": "bun --filter @scope/backend typecheck && bun --filter @scope/app-a check && ...",
    "lint:all": "bun --filter @scope/backend lint && ..."
  }
}

bun --filter <pkg> runs the script in that workspace only.


4. TypeScript Baseline

Root tsconfig.json (shared):

{
  "compilerOptions": {
    "lib": ["ESNext"], "target": "ESNext",
    "module": "Preserve", "moduleResolution": "bundler",
    "allowImportingTsExtensions": true, "verbatimModuleSyntax": true,
    "noEmit": true, "strict": true,
    "skipLibCheck": true,
    "noFallthroughCasesInSwitch": true,
    "noUncheckedIndexedAccess": true,
    "noImplicitOverride": true
  }
}

Each app extends it. Backend enables noUnusedLocals/Parameters: true; frontends rely on svelte-check. Use rewriteRelativeImportExtensions: true in SvelteKit apps.


5. Backend — apps/backend

5.1 Server Entry (src/server.ts)

  • Bun.serve({ fetch, websocket }) — single process.

  • Health checks + migration gate before listening.

  • Two oRPC handlers from the same router:

    import { RPCHandler } from "@orpc/server/fetch";
    import { OpenAPIHandler } from "@orpc/openapi/fetch";
    import { CORSPlugin } from "@orpc/server/plugins";
    import { CompressionPlugin } from "@orpc/server/fetch";
    import { OpenAPIReferencePlugin } from "@orpc/openapi/plugins";
    import { experimental_ValibotToJsonSchemaConverter } from "@orpc/valibot";
    
    const cors = new CORSPlugin({ origin: o => isAllowed(o) ? o : null });
    const rpcHandler = new RPCHandler(router, {
      plugins: [cors, new CompressionPlugin()],
    });
    const openApiHandler = new OpenAPIHandler(router, {
      plugins: [cors, new OpenAPIReferencePlugin({
        schemaConverters: [new experimental_ValibotToJsonSchemaConverter()],
        specGenerateOptions: { info, servers },
        docsProvider: "scalar", docsPath: "/docs", specPath: "/openapi.json",
      })],
    });
    
    Bun.serve({
      async fetch(req, server) {
        if (server.upgrade(req)) return undefined;
        const ctx = { request: req.clone(), db, valkey };
        if (new URL(req.url).pathname.startsWith("/rpc")) {
          const res = await rpcHandler.handle(req, { prefix: "/rpc", context: ctx });
          return res.matched ? res.response : new Response("Not Found", { status: 404 });
        }
        const res = await openApiHandler.handle(req, { context: ctx });
        return res.matched ? res.response : new Response("Not Found", { status: 404 });
      },
      websocket: { open, message(ws, raw) { handleWsMessage(ws, raw); }, close }
    });
    
  • Sentry + ORPCInstrumentation via onError interceptor.

  • Graceful shutdown via registerCleanup().

5.2 oRPC Router & Procedures

src/orpc/
├── router.ts        # assembles os.router({ ... })
├── context.ts       # ORPCContext = { request, db, valkey, user? }
├── middleware.ts    # authMiddleware, requirePermission(), caching
├── errors.ts        # throwBadRequest / throwNotFound / ORPCError helpers + responseCodes
├── procedures/      # one file per domain, each exports os.route(...).input(v.object).handler(...)
└── s3.ts / ws.ts    # infra helpers (storage via Bun S3Client — see §11 — / websocket)

Procedure pattern (Valibot validation + typed handler):

import { os } from "@orpc/server";
import * as v from "valibot";

export const myProcedure = os
  .route({
    method: "GET",            // also drives OpenAPI method
    path: "/v1/my-resource",  // also drives OpenAPI path
    tags: ["MyDomain"],
    summary: "...",
    description: "...",
  })
  .input(v.object({ id: v.string(), includeArchived: v.optional(v.string()) }))
  // .use(authMiddleware).use(requirePermission("my:read"))
  .handler(async ({ input, context }) => {
    // context: ORPCContext — typed DB + valkey + request
    return { status: "ok", code: "OK", data: { ... } };
  });

Then aggregate in router.ts:

import { os } from "@orpc/server";
import * as domain from "./procedures/domain";
export const orpcRouter = os.router({ healthz, livez, ...domain });
export type RouterType = typeof orpcRouter;

5.3 Dual Transport

Transport Handler URL Consumer
RPC (JSON over POST) RPCHandler /rpc/* First-party frontends via @orpc/client
REST / OpenAPI OpenAPIHandler path as defined in .route() (e.g. /v1/...) Third parties, Scalar docs at /docs, raw spec at /openapi.json

Both handlers share the same router instance — no duplication.

5.4 End-to-End Type Safety

The entire chain is type-checked without codegen:

backend/src/orpc/router.ts  →  export type RouterType
        │
        │  re-exported via
        ▼
backend/src/index.ts        →  export type RouterType = RouterClient<ORPCAppRouterType>
        │
        │  imported as devDependency  "@scope/backend": "workspace:*"
        ▼
frontend/src/lib/orpc.ts    →  createORPCClient<RouterType>(new RPCLink({ url: "/rpc", headers: ... }))
        │
        ▼
frontend code               →  orpc.myProcedure({ id: "..." })  // fully typed input/output

Backend exports types only (package.json exports: { ".": { "types": "./src/index.ts" } }). Frontends install @scope/backend as devDependencies so the import is erased at build.

Client setup (per frontend, src/lib/orpc.ts — must stay SSR-safe for adapter-node):

import { browser } from "$app/environment";
import { PUBLIC_API_BASE_URL } from "$env/static/public";
import { createORPCClient } from "@orpc/client";
import { RPCLink } from "@orpc/client/fetch";
import { ClientRetryPlugin, DedupeRequestsPlugin } from "@orpc/client/plugins";
import type { RouterType } from "@scope/backend";

// `.env`: PUBLIC_API_BASE_URL=https://api.example.com
// Never touch `window` / `localStorage` at module top-level — this module also runs on the server.
const baseUrl = browser && window.location.hostname === "localhost"
  ? "http://localhost:3000"
  : PUBLIC_API_BASE_URL;

const link = new RPCLink({
  url: `${baseUrl}/rpc`,
  headers: () => {
    if (!browser) return {};
    const t = localStorage.getItem("auth");
    return t ? { Authorization: `Bearer ${t}` } : {};
  },
  fetch: (req, init) => {
    const timeout = AbortSignal.timeout(30_000);
    const signal = init?.signal ? AbortSignal.any([init.signal, timeout]) : timeout;
    return globalThis.fetch(req, { ...init, signal });
  },
  plugins: [
    new ClientRetryPlugin({ default: { retry: 2, retryDelay: 1_000 } }),
    new DedupeRequestsPlugin({ filter: ({ request }) => request.method === "GET", groups: [{ condition: () => true, context: {} }] }),
  ],
});

export const orpc = createORPCClient<RouterType>(link);

Only needed when procedures return an envelope ({ status, code, data } as in §5.2). Plain procedure results need no unwrapping:

// packages/shared/orpc-unwrap.ts
export function unwrapOrpcResponse<T>(raw: { data: T } | T): T {
  if (typeof raw === "object" && raw !== null && "data" in raw) {
    return raw.data;
  }
  return raw;
}

5.5 End-to-End Type-Safe WebSocket Messages

oRPC covers request/response. For real-time push (chat, live updates) use the same single-source-of-truth principle via a shared event catalog and Valibot schemas.

Shared catalog (packages/shared/ws.ts — constants + Valibot schemas only, no server code):

export const ChatClientType = {
  NEW_MESSAGE: "newMessage",
  TYPING: "typing",
  // ...
} as const;
export const WsServerType = {
  MESSAGE: "message",
  TYPING: "typing",
  UNREAD_UPDATED: "unreadUpdated",
  ERROR: "error",
  // ...
} as const;

Both backend (apps/backend/src/ws-hub.ts, apps/backend/src/ws/handler.ts) and frontends import event names from @scope/shared/ws — event names can never drift.

Backend: typed handler map (apps/backend/src/ws/handler.ts + apps/backend/src/ws-hub.ts):

import * as v from "valibot";
import { ChatClientType, WsServerType } from "@scope/shared/ws";

const EnvelopeSchema = v.object({ type: v.string(), payload: v.optional(v.unknown()) });
type Envelope = v.InferOutput<typeof EnvelopeSchema>;
type AuthedClient = { userId: string };

const clients = new Map<ServerWebSocket, AuthedClient>();

const MessagePostSchema = v.object({
  target: v.string(),
  content: v.pipe(v.string(), v.maxLength(15_000)),
  media: v.optional(v.string()),
});

type WsHandler = (ws: ServerWebSocket, msg: Envelope, client: AuthedClient) => void;

function sendError(ws: ServerWebSocket, code: string): void {
  ws.send(JSON.stringify({ type: WsServerType.ERROR, data: code }));
}

export const authedHandlers: Partial<Record<string, WsHandler>> = {
  [ChatClientType.NEW_MESSAGE]: (ws, msg, client) => {
    const parsed = v.safeParse(MessagePostSchema, msg.payload);
    if (!parsed.success) { sendError(ws, "INVALID_PAYLOAD"); return; }
    // ... use parsed.output with full type safety
  },
  [ChatClientType.TYPING]: (ws, msg, client) => { /* ... */ },
};

// Single entry point wired to `Bun.serve({ websocket: { message } })` in `src/server.ts`
export function handleWsMessage(ws: ServerWebSocket, raw: string): void {
  let decoded: unknown;
  try {
    decoded = JSON.parse(raw);
  } catch {
    sendError(ws, "INVALID_PAYLOAD");
    return;
  }
  const envelope = v.safeParse(EnvelopeSchema, decoded);
  if (!envelope.success) { sendError(ws, "INVALID_PAYLOAD"); return; }
  const client = clients.get(ws);
  if (!client) { sendError(ws, "NOT_AUTHED"); return; }
  authedHandlers[envelope.output.type]?.(ws, envelope.output, client);
}

Valibot gives the same DX as oRPC procedures: parse once, then parsed.output is fully typed — no type assertions needed.

Frontend: typed wrapper (apps/<frontend>/src/lib/websocket.ts):

import { browser } from "$app/environment";
import { PUBLIC_API_BASE_URL } from "$env/static/public";
import type { ChatSocketPayload } from "@scope/shared/ws";

// Native WebSocket served by `Bun.serve` (`src/server.ts` + `ws-hub` fan-out).
// Auth goes via `?auth=` query param. Reconnect + lifecycle (e.g. Capacitor
// `resume`/`background`) is handled by the caller.
let socket: WebSocket | null = null;

export function createWebsocket(auth: string): WebSocket {
  const wsUrl = new URL(`${PUBLIC_API_BASE_URL.replace(/^http/, "ws")}/v1/ws`);
  wsUrl.searchParams.set("auth", auth);
  socket = new WebSocket(wsUrl.toString());
  return socket;
}

export function sendWebsocket(msg: ChatSocketPayload): void {
  if (browser) socket?.send(JSON.stringify(msg));
}

Note: oRPC covers request/response (RPC + OpenAPI streaming where needed). Push messages (chat, live updates) use a separate native WebSocket served by the same Bun.serve({ fetch, websocket: { open, message, close } }) in src/server.ts with fan-out in ws-hub.ts.

Why this works:

  • Event names are a shared as const union — rename in one place, TS errors everywhere.
  • Payloads are Valibot schemas shared or mirrored backend/frontend — no any / as unknown / as assertions needed.
  • The authedHandlers map is the websocket equivalent of os.router(): a single registry guarantees both sides agree on the contract.
  • Keep packages/shared free of server-only code; only the event constants (and optionally shared Valibot schemas) live there.

6. Frontend Apps — SvelteKit + Capacitor Native

6.0 Capacitor Native Integration (apps/mobile)

The mobile app is a SvelteKit SPA + Capacitor hybrid — same build/ output runs on web and as a native iOS/Android shell.

  • Config: apps/mobile/capacitor.config.ts:1-31 — appId: "mobile.my.app", webDir: "build", platform ios/android flags (allowsLinkPreview: false, zoomEnabled: false), Keyboard (resize: Body, style: Light) and push (FirebaseMessaging / PushNotifications with badge/sound/alert). Native shells live in apps/mobile/android/ and apps/mobile/ios/ (generated, platform-specific .gitignore).
  • Build pipeline: apps/mobile/package.json:6-12 exposes build:native, build:native:android, build:native:ios:
    bun run build                  # vite build → build/
    bunx cap sync                  # copy web assets into android/ios
    bun run set:versions           # syncs version → Info.plist / project.pbxproj / build.gradle
    bunx cap open android|ios      # opens Xcode / Android Studio
    
    set-native-version.ts:1-74 derives versionCode (major*10000 + minor*100 + patch) and MARKETING_VERSION from package.json/APP_VERSION and patches ios/App/App/Info.plist, ios/App/App.xcodeproj/project.pbxproj, android/app/build.gradle.
  • Plugins in use (apps/mobile/package.json:68-99): @capacitor/core|cli|android|ios, @capacitor/app|app-launcher|camera|clipboard|device|dialog|filesystem|haptics|inappbrowser|keyboard|network|preferences|share, @capacitor-firebase/messaging, @capawesome/capacitor-badge, @ebarooni/capacitor-calendar, @capacitor-community/* (in-app-review, media), capacitor-native-settings. All access is via @capacitor/* ESM imports — no native code in JS besides Capacitor.isNativePlatform() guards.
  • WebSocket + Capacitor: Real-time uses the same @scope/shared/ws catalog. On native, Capacitor network/keyboard lifecycle is respected (reconnect on resume, pause on background), but the transport stays standard WebSocket (Bun.serve websocket) — no extra native socket plugin required.
  • Rule: Never commit android/build/, ios/App/public/, or capacitor.config.json copies inside ios/App/App/; they are generated by cap sync. Version bumps go through set-native-version.ts, not hand-edits.

6.1 Per-App Package

Each frontend has its own package.json with independent version and release-it config (see §13).

6.2 Vite + SvelteKit Config

  • SPA frontends → adapter-static with SPA fallback + nginx:

    // vite.config.ts
    import adapterStatic from "@sveltejs/adapter-static";
    import { sveltekit } from "@sveltejs/kit/vite";
    import tailwindcss from "@tailwindcss/vite";
    export default defineConfig({
      plugins: [tailwindcss(), sveltekit({
        adapter: adapterStatic({ pages: "build", assets: "build", fallback: "app.html", precompress: true }),
      })],
      ssr: { noExternal: ["@scope/shared"] },
    });
    

    Dockerfile is two-stage: ghcr.io/philippdormann/bun:1.4.2 → bun run build → nginx:1.29-alpine3.23-slim (alpine) serving build/ with nginx.conf that does try_files $uri /app.html and long-cache on /_app.

  • SSR website → adapter-node running build/index.js on PORT=80. Build + runtime both use ghcr.io/philippdormann/bun:1.4.2 (apps/website/Dockerfile:1,34) — minimal + zero CVE, prefer ghcr.io/philippdormann/bun everywhere (see §12 Docker policy).

6.3 Nginx Pattern (nginx.conf)

server {
  error_page 404 /app.html;          # or /index.html — must match adapter fallback
  location /_app { expires 1y; }     # hashed SvelteKit assets — immutable
  location / { try_files $uri $uri/ /app.html; }
  gzip on; gzip_types text/css application/javascript ...;
}

6.4 Conventions Inside Frontends

  • #lib import alias via package.json imports: { "#lib": "./src/lib/index.js" }.
  • src/lib/orpc.ts owns the typed client (above).
  • Validation on the client with valibot mirrors backend schemas but is not coupled — backend is source of truth.
  • Sentry @sentry/browser per frontend.

6.5 UI Components — Prefer shadcn-svelte

Policy: For frontend design, prefer components from shadcn-svelte where available — do not hand-roll primitives that already exist there. shadcn-svelte is Tailwind 4 + Bits UI based, accessible, and matches the stack's styling (see §8). Use the CLI to vendor components into each app; customize via components.json / CSS variables, not by forking.

  • Install per frontend: bunx shadcn-svelte@latest init then bunx shadcn-svelte@latest add button card dialog ... (see SvelteKit install + CLI + theming). Components vendor to src/lib/components/ui/ — committed, not from node_modules.
  • Class merging: Frontends use cn — import { cn } from "cn" — as drop-in replacement for clsx + tailwind-merge (and cnfast). Do not add clsx, tailwind-merge, or cnfast in frontend projects. If cn is needed: bun add -E cn, then cn("px-2 py-1", isActive && "bg-blue-500", { "text-white": isActive }, className). Legacy lib/utils.ts wrapper (twMerge(clsx(...))) is replaced by export { cn } from "cn" — see cn docs + npx shadcn@latest migrate cn for existing projects.
  • Available primitives (check llms.txt for full catalog): button, input, select, checkbox, dialog, drawer, dropdown-menu, tabs, card, table, data-table (TanStack), chart (LayerChart), sonner (toast), avatar, skeleton, carousel, etc. — use these before building custom.
  • Keep app-specific composites on top of shadcn primitives (e.g. UserCard.svelte composes Card + Avatar + Badge from ui/). Truly shared UI (e.g. Logo.svelte, format-date) stays in packages/shared; shadcn primitives stay per-app (they vendor per project by design).
  • Theming: CSS variables + Tailwind 4 — configure in components.json and app.css per theming and Tailwind v4 migration.

6.6 Default UX Decisions

Policy: Apply these UX defaults to every frontend unless a feature explicitly opts out.

  • Confirm modals for critical interactions: Destructive or irreversible actions (delete, bulk delete, archive, publish, invite revoke, payment, permission change) must use a confirmation modal — never window.confirm/alert (see §15). Prefer AlertDialog from shadcn-svelte (see Alert Dialog) for destructive confirms and Dialog for non-destructive. Modal must state the impact in plain language, show the count/names of affected items, require explicit confirm (e.g. Delete 3 items button, disabled until acknowledged), and offer undo where feasible. No critical action on single-click without confirm.

  • Default to non-technical user-facing texts: All copy shown to end users defaults to plain, non-technical language (DE/EN). Avoid jargon, error codes, stack traces, table/column names, or IDs in UI. Map backend errors to friendly messages ("Could not save — please try again." not "500: pg error 23505"). Keep technical details in logs/Sentry, not in toasts/alerts. Write copy for easy i18n later — no concatenated strings, use complete sentences with placeholders.

  • Bulk actions where useful: Any list/table with ≥3 items that supports a per-item destructive or state-changing action must also offer bulk selection + bulk action. Use Checkbox + Data Table row selection (shadcn-svelte Data Table / Checkbox) with a sticky bulk bar (e.g. 3 selected — Delete | Archive | Export). Bulk actions reuse the same confirm modal (with count) and show progress / partial-failure handling. Design empty- and single-selection states explicitly.


7. Shared Package — packages/shared

packages/shared/package.json
{
  "name": "@scope/shared",
  "private": true,
  "type": "module",
  "exports": {
    "./format-date": { "types": "./format-date.ts", "default": "./format-date.ts" },
    "./Logo.svelte": { "types": "./Logo.svelte", "default": "./Logo.svelte" },
    ...
  }
}
  • No build step — consumers import .ts/.svelte directly. Vite/SvelteKit + Bun handle it (ssr.noExternal).
  • Holds only framework-agnostic helpers and presentational Svelte components (date formatting, debounce, storage, avatar placeholders, etc.). Do not duplicate shadcn-svelte primitives in shared — shadcn components vendor per-app via the CLI (see §6.5); only truly cross-app composites belong in shared.
  • Keep server-only code out of shared.

8. Styling / UI / Linting / Formatting

UI Policy: Frontend styling is Tailwind 4 + shadcn-svelte primitives (see §6.5) + cn for class merging. Prefer https://www.shadcn-svelte.com/llms.txt components (button, card, dialog, table, sonner, etc.) over custom CSS/primitives — install via shadcn-svelte CLI and theme via CSS variables (components.json). Use cn from "cn" (not clsx/tailwind-merge/cnfast) — cn("px-2 py-1", condition && "bg-blue-500", className).

Fonts Policy: Use Fontsource (@fontsource/*) self-hosted via bun add -E @fontsource/<font> — do not load Google Fonts CDN (fonts.googleapis.com / fonts.gstatic.com). Improves privacy (no third-party request), offline, CSP-friendly. Import in src/app.css or +layout.svelte: import "@fontsource/inter/latin-400.css" / import "@fontsource/inter/latin-700.css" — pick subsets/weights explicitly. See fontsource.org.

App Formatter Linter Typecheck
backend, cdn oxfmt (oxfmt --check) oxlint tsc --noEmit
Frontends prettier + prettier-plugin-svelte + prettier-plugin-tailwindcss eslint + eslint-plugin-svelte + typescript-eslint svelte-check --tsconfig ./tsconfig.json

Root lint:all / check scripts fan out with bun --filter.

Migration plan: Backend/CDN already run on oxfmt + oxlint. Frontends stay on prettier + eslint-plugin-svelte until oxc ships full Svelte/SvelteKit support — per oxc compatibility oxlint has no Svelte template linting yet (oxc#15761) and oxfmt for Svelte/SvelteKit still requires installing svelte/compiler separately. Once full support lands, migrate frontends to oxfmt + oxlint and drop prettier/eslint.


9. Database & ORM — Postgres Preferred

Policy: Postgres is the preferred database for all new code in this template. Drizzle is wired via drizzle-orm/bun-sql (apps/backend/src/db.ts:2, drizzle.config.ts:6 dialect: "postgresql"). MySQL/MariaDB is legacy / avoid — do not introduce mysql2/drizzle-orm/mysql-core for new features or new services.

No extra DB driver needed: Postgres access uses Bun's built-in Bun.SQL (import { SQL } from "bun" / import { sql } from "bun"). Do not install pg, postgres (porsager/postgres), mysql2, or better-sqlite3 — Bun ships a native SQL client with pooling, prepared statements, and a unified API for Postgres/MySQL/SQLite. See Bun SQL docs. Drizzle plugs into it via drizzle-orm/bun-sql.

  • Single Drizzle client in apps/backend/src/db.ts:1-25 — thin wrapper over the native client:

    import { SQL } from "bun"; // Bun built-in — no npm driver to install
    import { drizzle } from "drizzle-orm/bun-sql"; // or "drizzle-orm/bun-sql/postgres"
    const client = new SQL(process.env.DATABASE_URL!, { max: 10, idleTimeout: 30 });
    export const db = drizzle({ client });
    export async function checkDbConnection() { await db.execute(sql`SELECT 1`); }
    // Raw Bun SQL also works without Drizzle: await client`SELECT 1`
    
  • Schema under src/db/schema/ — all imports from drizzle-orm/pg-core (apps/backend/src/db/schema.ts:12, src/index.ts:12 drizzle-orm/pg-core/migrator). Snapshots are dialect: "postgres" (apps/backend/drizzle/*/snapshot.json:5), service image is postgres:18.0-alpine3.22 (apps/backend/docker-compose.yml:14) and DATABASE_URL=postgresql://... (apps/backend/docker-compose.yml:10, .env.sample:7).

  • Generated via drizzle-kit pull (introspects existing DB) + drizzle-kit generate/migrate.

  • src/db/migrate.ts runs on startup when RUN_DB_MIGRATIONS_ON_STARTUP=true (see apps/backend/src/index.ts:48-55).

  • Keep migrations out of version control noise: PRs should not contain generated SQL — generate after merge (enforced via PR template).


10. Caching / Queues / Jobs

  • Valkey (single instance, Redis-compatible) for auth cache, rate limits, de-duplication.
  • BullMQ for mail queues, push queues.
  • croner for cron jobs (src/cron/ + src/scheduled.ts), guarded by a Valkey distributed lock so only one replica runs.
  • Image/proxy helpers (imageproxy.ts, orpc/s3.ts) build presigned URLs in the backend and cache them where possible — frontends never construct storage URLs (see §11 for S3).

11. Object Storage — S3 via Bun-native S3Client

No extra S3 SDK needed: S3 access uses Bun's built-in S3Client (import { S3Client } from "bun" / import { s3 } from "bun"). Do not install @aws-sdk/client-s3, @aws-sdk/s3-presigner, aws-sdk, or minio — Bun ships a native S3 client with S3File (Blob-compatible), presigning, streaming/multipart, and s3:// in fetch/Bun.file. See Bun S3 docs.

Policy: Prefer a single central S3Client per bucket (e.g. apps/backend/src/lib/s3.ts). Pass it around; do not create ad-hoc new S3Client() in every procedure/handler.

  • Central client in apps/backend/src/lib/s3.ts:1-25 — sole place that reads env/credentials:

    import { S3Client } from "bun"; // Bun built-in — no npm S3 SDK to install
    
    // Single central client per bucket — reuse everywhere
    export const s3 = new S3Client({
      bucket: process.env.S3_BUCKET!,
      // credentials auto-read from S3_ACCESS_KEY_ID / S3_SECRET_ACCESS_KEY
      // (falls back to AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY) or set explicitly:
      // accessKeyId: process.env.S3_ACCESS_KEY_ID!,
      // secretAccessKey: process.env.S3_SECRET_ACCESS_KEY!,
      // endpoint: process.env.S3_ENDPOINT, // R2 / MinIO / Spaces / Supabase — omit for AWS
      // region: process.env.S3_REGION, // e.g. "us-east-1" / "auto" for R2
    });
    
    // Optional: also export a helpers bucket for presigned URL / exists checks
    export function s3File(key: string) { return s3.file(key); } // lazy S3File ref
    
  • Usage from procedures / helpers (src/orpc/s3.ts:1-40, src/orpc/procedures/*):

    import { s3, s3File } from "../../lib/s3.ts";
    
    // Read (Blob-compatible — same API as Bun.file)
    const text = await s3File("user/123.json").text();
    const json = await s3File("user/123.json").json();
    const stream = s3File("large.bin").stream();
    
    // Write / upload (auto multipart for large streams)
    await s3.write("uploads/avatar.png", imageBuffer, { type: "image/png" });
    // or
    await s3File("uploads/avatar.png").write(imageBuffer, { type: "image/png" });
    await Bun.write(s3File("uploads/report.pdf"), pdfBytes);
    
    // Presign (sync — no network request) — backend builds, frontend uses
    const downloadUrl = s3File("private/doc.pdf").presign({ expiresIn: 3600 });
    const uploadUrl = s3File("uploads/incoming.jpg").presign({ method: "PUT", expiresIn: 600, type: "image/jpeg" });
    
    // Exists / delete / stat
    const exists = await s3.exists("uploads/avatar.png");
    await s3.delete("tmp/old.json");
    const { size, etag } = await s3File("uploads/avatar.png").stat();
    
  • Credentials: S3Client auto-reads S3_ACCESS_KEY_ID, S3_SECRET_ACCESS_KEY, S3_BUCKET, S3_ENDPOINT, S3_REGION (with AWS_* fallbacks) from env/.env — no process.env plumbing needed. Override per-client via constructor options — see Bun S3 credentials. Works with AWS S3, Cloudflare R2 (endpoint: "https://<account>.r2.cloudflarestorage.com"), DigitalOcean Spaces, MinIO (endpoint: "http://localhost:9000"), Supabase — same S3Client.

  • Keep packages/shared free of S3 code; only the presigned URL string leaves the backend. Frontends never construct s3:// paths or call S3Client — they receive https://... presigned URLs via oRPC (see §5.2 orpc/s3.ts).


12. Deployment

Docker — Minimal + Zero CVE Only

Policy: Use ghcr.io/philippdormann/bun:1.4.2 (minimal + zero CVE) for all Bun build/runtime stages. All Dockerfiles in this repo follow this — do not introduce oven/bun, debian/slim-bullseye/ubuntu bases for new services without justification.

  • One *.Dockerfile per app at repo root (so docker build -f apps/<app>/Dockerfile . gets the whole monorepo context — actual files at apps/backend/Dockerfile:1, apps/mobile/Dockerfile:1, apps/website/Dockerfile:1).
  • Backend/CDN: single-stage ghcr.io/philippdormann/bun:1.4.2 (apps/backend/Dockerfile:1), bun install --filter @scope/<app> --production, CMD ["bun","run","src/index.ts"].
  • Frontends (static): multi-stage ghcr.io/philippdormann/bun:1.4.2 build → nginx:1.29-alpine3.23-slim serve (apps/mobile/Dockerfile:1, apps/mobile/Dockerfile:28). The -alpine-slim nginx variant is still alpine-based.
  • Website (SSR): multi-stage ghcr.io/philippdormann/bun:1.4.2 build → ghcr.io/philippdormann/bun:1.4.2 runtime (apps/website/Dockerfile:1, apps/website/Dockerfile:34 production stage, bun run build/index.js). No node:*-alpine mixing unless strictly needed.
  • Verification: CI builds all images via docker buildx --platform linux/arm64 (.gitlab-ci.yml:61); local parity via docker-compose.yml per app (apps/*/docker-compose.yml). If you must use a non-minimal base, document why in the Dockerfile header comment.

Caddy (Caddyfile)

Caddy terminates TLS and reverse-proxies per subdomain:

api.example.com      → backend:3000
cdn.example.com      → cdn:3000
app.example.com      → frontend-a:80
admin.example.com    → frontend-b:80
example.com          → website:80

Add redir blocks for legacy URL compatibility (keep old links working).

Local Dev (docker-compose.yml)

valkey, mailpit, backend, and each frontend (:4000, :4001, …) for parity. Backend waits for valkey: healthy.

Security — bun audit (CI Step 0)

Requirement: Every tagged build runs bun audit as stage 0 before any build job. Fix vulnerabilities before the build is allowed to start.

In this repo (.gitlab-ci.yml:1-14):

stages: [audit, build]           # audit is step 0 — always first
audit:
  stage: audit
  image: ghcr.io/philippdormann/bun:1.4.2    # minimal + zero CVE (see Docker policy)
  rules:
    - if: $CI_COMMIT_TAG =~ /^backend-.*$/
    - if: $CI_COMMIT_TAG =~ /^website-.*$/
    - if: $CI_COMMIT_TAG =~ /^mobile-.*$/
  script: [bun audit]             # fails the pipeline on audit findings
  • docker-build-* jobs all have needs: [audit] (and docker-build-backend additionally needs: [audit, typecheck-backend] — .gitlab-ci.yml:69-89), so the audit gate is blocking — builds never start if auditing fails.
  • Locally, run bun audit before pushing a release tag. For a quick pre-release check: bun audit --help and bun pm pack for advisory details.
  • This gate exists to catch CVEs in the single bun.lock workspace before images are built/pushed.

13. Release Flow — Independent Per-App Versioning

Each app has its own version in apps/<app>/package.json and a release-it block:

{
  "version": "1.2.3",
  "scripts": { "release": "release-it" },
  "release-it": {
    "git": {
      "commit": true, "push": true, "tag": true,
      "requireBranch": "main", "requireCleanWorkingDir": true,
      "commitMessage": "chore(release): <app>-${version}",
      "tagName": "<app>-${version}", "tagAnnotation": "<app>-${version}"
    },
    "npm": { "publish": false },
    "hooks": { "after:bump": "bun run build-versioninfo.ts && git add versioninfo.ts" }
  }
}

Flow:

  1. bun --filter @scope/<app> release (or release-it directly) bumps package.json, creates commit chore(release): <app>-x.y.z, tags <app>-x.y.z, pushes.

  2. .gitlab-ci.yml has one job per app, triggered only by matching tag. All jobs are gated by audit (step 0) — builds only run after bun audit passes (.gitlab-ci.yml:69-89 needs: [audit]):

    audit:                          # stage: audit — step 0, blocking gate
      stage: audit
      image: ghcr.io/philippdormann/bun:1.4.2
      script: [bun audit]
    
    backend:                        # stage: build
      needs: [audit, typecheck-backend]
      rules: [{ if: '$CI_COMMIT_TAG =~ /^backend-\d+\.\d+\.\d+$/' }]
      script:
        - docker buildx build --platform linux/arm64 -f "apps/$APP_NAME/Dockerfile"
            -t "$CI_REGISTRY_IMAGE:$CI_COMMIT_TAG" --push .
    

    This produces floating major tags like backend-4-arm64, frontend-a-2-arm64 — deploy pulls the major tag, no redeploy config change for patch/minor.

  3. The same pattern applies to every frontend and cdn/website. Every Dockerfile must use ghcr.io/philippdormann/bun:1.4.2 (minimal + zero CVE) for Bun stages (see §12 Docker policy); non-minimal bases require justification.

Optional scripts/update-*.ts codegen runs in prebuild of frontends (e.g. generate appversions.ts from the live API) so builds stay in sync.


14. How to Replicate This Pattern From Scratch

  1. Init monorepo:
    bun init
    # root package.json: { "private": true, "workspaces": ["packages/*", "apps/*"] }
    
  2. Add shared:
    mkdir -p packages/shared
    # packages/shared/package.json with "exports" map, no build
    
  3. Add backend:
    mkdir -p apps/backend/src/{orpc/procedures,db,middleware}
    bun add -E @orpc/server @orpc/openapi @orpc/valibot valibot drizzle-orm
    # DB: no extra driver — use Bun's built-in SQL (https://bun.sh/docs/runtime/sql)
    # via `import { SQL } from "bun"` + `drizzle-orm/bun-sql`. Do NOT add pg/postgres/mysql2.
    # S3: no extra SDK — use Bun's built-in S3Client (https://bun.sh/docs/runtime/s3)
    # via `import { S3Client } from "bun"`. Do NOT add @aws-sdk/* / aws-sdk / minio.
    # src/server.ts: Bun.serve entry (sole listen point)
    # src/index.ts: type-only `export type RouterType = RouterClient<typeof router>`
    # package.json: { "name": "@scope/backend", "exports": { ".": { "types": "./src/index.ts" } } }
    
  4. Add a frontend:
    bunx sv create apps/app-a   # choose SvelteKit + TS
    # add to apps/app-a/package.json:
    #   "devDependencies": { "@scope/backend": "workspace:*" }
    #   "dependencies": { "@scope/shared": "workspace:*", "@orpc/client": "..." }
    # create src/lib/orpc.ts as in §5.4 (SSR-safe: PUBLIC_API_BASE_URL + browser guard)
    # vite.config.ts: ssr.noExternal = ["@scope/shared"]
    # UI: prefer shadcn-svelte — https://www.shadcn-svelte.com/llms.txt (see §6.5)
    #   bunx shadcn-svelte@latest init && bunx shadcn-svelte@latest add button card dialog input select tabs
    # class merging: use `cn` from "cn" (https://npmx.dev/package/cn) — not clsx/tailwind-merge/cnfast
    #   bun add -E cn && export { cn } from "cn" in lib/utils.ts
    
  5. Wire e2e types: Import RouterType from @scope/backend in src/lib/orpc.ts and create the client. No codegen step needed — TS resolves via workspace:*.
  6. Add Dockerfiles per app at apps/<app>/Dockerfile (file lives here, always build with repo-root context docker build -f apps/<app>/Dockerfile .; all Bun stages minimal + zero CVE — ghcr.io/philippdormann/bun:*, nginx:*-alpine*-slim; see §12) + Caddyfile + docker-compose.yml.
  7. Add release-it per app (tagName: "<app>-${version}") and a CI job per app filtered on ^<app>-\d+\.\d+\.\d+$, gated by an audit stage 0 running bun audit with needs: [audit] on every build job (see §12 Security).
  8. Add Postgres as default DB — use Bun's built-in Bun.SQL (SQL from "bun"), no external driver (pg/postgres/mysql2 not needed) — see Bun SQL docs. Wire Drizzle via drizzle-orm/bun-sql (postgres:*-alpine, dialect: "postgresql" — see §9) and avoid MySQL/MariaDB for new code.
  9. Add Valkey for cache/queues (valkey/valkey:*-alpine, Redis-compatible — see §10): single valkey service in docker-compose.yml, ioredis client + BullMQ queues in backend, Valkey distributed lock for croner jobs.
  10. Add S3 object storage — use Bun's built-in S3Client (new S3Client() from "bun"), no @aws-sdk/* / minio needed — see Bun S3 docs and §11. Create central apps/backend/src/lib/s3.ts with export const s3 = new S3Client({ bucket: process.env.S3_BUCKET! }) and reuse via s3.file() / s3.write() / .presign() (see §11).
  11. Add Capacitor integration for native shells if needed: capacitor.config.ts (webDir: "build"), bunx cap sync, version sync scripts, plugins via @capacitor/* (see §6.0).
  12. Add root scripts: check, lint:all via bun --filter.

15. Conventions & Gotchas

  • Timestamps: Store as UNIX seconds in DB, return UNIX seconds from backend, format in frontend (DD.MM.YYYY HH:mm default; DD.MM.YYYY without time; Europe/Berlin for mails). Avoid Date strings in the DB.
  • Asset URLs: Always built in the backend (presigned + cached via Bun S3Client — see §11). Frontends never interpolate storage paths or construct s3:// URLs.
  • UI components: Prefer shadcn-svelte where available (see §6.5) — do not re-implement button/card/dialog/table/sonner etc. when the registry provides them. Vendor via CLI, theme via components.json. For class merging use cn (import { cn } from "cn") — not clsx/tailwind-merge/cnfast.
  • Fonts: Use Fontsource self-hosted (@fontsource/* via bun add -E) — do not use Google Fonts CDN. Privacy-first, no external request, explicit subsets/weights (see §8 Fonts Policy).
  • Notifications (e.g. Telegram): Never include PII; link to profile/ID instead.
  • CORS: Central isOriginAllowed() used by both CORSPlugin instances (RPC + OpenAPI) and fallback 404 handler.
  • Auth: Validate JWT in middleware, cache resolved user in Valkey (auth:user:<id> or auth:user:<id>:tenant:<tenantId> for multi-tenant setups), invalidate on permission change. Guard against cross-user-type token reuse by checking usertype/role claims.
  • OpenAPI docs: OpenAPIReferencePlugin with scalar + experimental_ValibotToJsonSchemaConverter auto-derives the spec from the same os.route() definitions — no manual spec.
  • No as assertions (except as const): No : any, as any, as unknown, as unknown as, as Record, as string, or other type assertions that kill type safety. Prefer discriminated unions, in / typeof narrowing, and Valibot inference (v.safeParse + parsed.output, v.InferOutput). Keep strict on.
  • Commits: Conventional commits with optional scope (fix(backend): ...), small commits, no generated migrations in PRs.
  • Confirm modals for critical interactions: Never use window.alert/confirm/prompt — use shadcn-svelte AlertDialog/Dialog (see §6.6). Every destructive/irreversible action requires explicit confirm modal with plain-language impact + count.
  • Non-technical user-facing texts by default: Default all UI copy to plain language (DE/EN), no jargon/IDs/codes in toasts or errors; map technical errors to friendly messages (see §6.6).
  • Bulk actions where useful: Lists/tables with per-item mutations must also expose multi-select + bulk bar + bulk confirm (see §6.6).

16. Further Reading

  • oRPC docs: https://orpc.dev/llms.txt and subpages (canonical reference for os, RPCHandler, OpenAPIHandler, RPCLink).
  • Svelte 5 runes: https://svelte.dev/docs/svelte/v5-migration-guide
  • shadcn-svelte (prefer where available — UI components via CLI, Tailwind 4 + Bits UI): https://www.shadcn-svelte.com/llms.txt (catalog: button, card, dialog, table, sonner, chart, data-table, etc.)
  • cn (class merging for Tailwind — use instead of clsx/tailwind-merge/cnfast in frontends): https://npmx.dev/package/cn (import { cn } from "cn")
  • Fontsource (self-hosted fonts — use instead of Google Fonts CDN, privacy-first): https://fontsource.org
  • Drizzle ORM: https://orm.drizzle.team
  • Bun SQL (native driver, no pg/postgres/mysql2 needed): https://bun.sh/docs/runtime/sql
  • Bun S3 (native S3Client, no @aws-sdk/* needed — central new S3Client() per bucket): https://bun.sh/docs/runtime/s3
  • Release-it: https://github.com/release-it/release-it