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.
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(nevernpx, never hand-editdependencies).Inter-package deps are
workspace:*:{ "dependencies": { "@scope/shared": "workspace:*" } }Frontends depend on backend as
devDependencyonly — they need its types, not its runtime:{ "devDependencies": { "@scope/backend": "workspace:*" } }Optional:
patchedDependenciesfor 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 +
ORPCInstrumentationviaonErrorinterceptor.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 } })insrc/server.tswith fan-out inws-hub.ts.
Why this works:
- Event names are a shared
as constunion — rename in one place, TS errors everywhere. - Payloads are Valibot schemas shared or mirrored backend/frontend — no
any/as unknown/asassertions needed. - The
authedHandlersmap is the websocket equivalent ofos.router(): a single registry guarantees both sides agree on the contract. - Keep
packages/sharedfree 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", platformios/androidflags (allowsLinkPreview: false,zoomEnabled: false),Keyboard(resize: Body,style: Light) and push (FirebaseMessaging/PushNotificationswithbadge/sound/alert). Native shells live inapps/mobile/android/andapps/mobile/ios/(generated, platform-specific.gitignore). - Build pipeline:
apps/mobile/package.json:6-12exposesbuild: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 Studioset-native-version.ts:1-74derivesversionCode(major*10000 + minor*100 + patch) andMARKETING_VERSIONfrompackage.json/APP_VERSIONand patchesios/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 besidesCapacitor.isNativePlatform()guards. - WebSocket + Capacitor: Real-time uses the same
@scope/shared/wscatalog. On native,Capacitornetwork/keyboard lifecycle is respected (reconnect onresume, pause onbackground), but the transport stays standard WebSocket (Bun.servewebsocket) — no extra native socket plugin required. - Rule: Never commit
android/build/,ios/App/public/, orcapacitor.config.jsoncopies insideios/App/App/; they are generated bycap sync. Version bumps go throughset-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-staticwith 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) servingbuild/withnginx.confthat doestry_files $uri /app.htmland long-cache on/_app.SSR website →
adapter-noderunningbuild/index.jsonPORT=80. Build + runtime both useghcr.io/philippdormann/bun:1.4.2(apps/website/Dockerfile:1,34) — minimal + zero CVE, preferghcr.io/philippdormann/buneverywhere (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
#libimport alias viapackage.jsonimports: { "#lib": "./src/lib/index.js" }.src/lib/orpc.tsowns the typed client (above).- Validation on the client with
valibotmirrors backend schemas but is not coupled — backend is source of truth. - Sentry
@sentry/browserper 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 initthenbunx shadcn-svelte@latest add button card dialog ...(see SvelteKit install + CLI + theming). Components vendor tosrc/lib/components/ui/— committed, not fromnode_modules. - Class merging: Frontends use
cn—import { cn } from "cn"— as drop-in replacement forclsx+tailwind-merge(andcnfast). Do not addclsx,tailwind-merge, orcnfastin frontend projects. Ifcnis needed:bun add -E cn, thencn("px-2 py-1", isActive && "bg-blue-500", { "text-white": isActive }, className). Legacylib/utils.tswrapper (twMerge(clsx(...))) is replaced byexport { cn } from "cn"— see cn docs +npx shadcn@latest migrate cnfor existing projects. - Available primitives (check
llms.txtfor 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.sveltecomposesCard+Avatar+Badgefromui/). Truly shared UI (e.g.Logo.svelte,format-date) stays inpackages/shared; shadcn primitives stay per-app (they vendor per project by design). - Theming: CSS variables + Tailwind 4 — configure in
components.jsonandapp.cssper 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). PreferAlertDialogfrom shadcn-svelte (see Alert Dialog) for destructive confirms andDialogfor non-destructive. Modal must state the impact in plain language, show the count/names of affected items, require explicit confirm (e.g.Delete 3 itemsbutton, 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 Tablerow 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/.sveltedirectly. 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 inshared. - 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) +
cnfor class merging. Preferhttps://www.shadcn-svelte.com/llms.txtcomponents (button,card,dialog,table,sonner, etc.) over custom CSS/primitives — install viashadcn-svelteCLI and theme via CSS variables (components.json). Usecnfrom"cn"(notclsx/tailwind-merge/cnfast) —cn("px-2 py-1", condition && "bg-blue-500", className).
Fonts Policy: Use Fontsource (
@fontsource/*) self-hosted viabun 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 insrc/app.cssor+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 onprettier+eslint-plugin-svelteuntil oxc ships full Svelte/SvelteKit support — per oxc compatibilityoxlinthas no Svelte template linting yet (oxc#15761) andoxfmtfor Svelte/SvelteKit still requires installingsvelte/compilerseparately. Once full support lands, migrate frontends tooxfmt+oxlintand dropprettier/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:6dialect: "postgresql"). MySQL/MariaDB is legacy / avoid — do not introducemysql2/drizzle-orm/mysql-corefor 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 installpg,postgres(porsager/postgres),mysql2, orbetter-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 viadrizzle-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 fromdrizzle-orm/pg-core(apps/backend/src/db/schema.ts:12,src/index.ts:12drizzle-orm/pg-core/migrator). Snapshots aredialect: "postgres"(apps/backend/drizzle/*/snapshot.json:5), service image ispostgres:18.0-alpine3.22(apps/backend/docker-compose.yml:14) andDATABASE_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.tsruns on startup whenRUN_DB_MIGRATIONS_ON_STARTUP=true(seeapps/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, orminio— Bun ships a native S3 client withS3File(Blob-compatible), presigning, streaming/multipart, ands3://infetch/Bun.file. See Bun S3 docs.
Policy: Prefer a single central
S3Clientper bucket (e.g.apps/backend/src/lib/s3.ts). Pass it around; do not create ad-hocnew 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 refUsage 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:
S3Clientauto-readsS3_ACCESS_KEY_ID,S3_SECRET_ACCESS_KEY,S3_BUCKET,S3_ENDPOINT,S3_REGION(withAWS_*fallbacks) from env/.env— noprocess.envplumbing 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 — sameS3Client.Keep
packages/sharedfree of S3 code; only the presigned URL string leaves the backend. Frontends never constructs3://paths or callS3Client— they receivehttps://...presigned URLs via oRPC (see §5.2orpc/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 introduceoven/bun,debian/slim-bullseye/ubuntubases for new services without justification.
- One
*.Dockerfileper app at repo root (sodocker build -f apps/<app>/Dockerfile .gets the whole monorepo context — actual files atapps/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.2build →nginx:1.29-alpine3.23-slimserve (apps/mobile/Dockerfile:1,apps/mobile/Dockerfile:28). The-alpine-slimnginx variant is still alpine-based. - Website (SSR): multi-stage
ghcr.io/philippdormann/bun:1.4.2build →ghcr.io/philippdormann/bun:1.4.2runtime (apps/website/Dockerfile:1,apps/website/Dockerfile:34productionstage,bun run build/index.js). Nonode:*-alpinemixing unless strictly needed. - Verification: CI builds all images via
docker buildx --platform linux/arm64(.gitlab-ci.yml:61); local parity viadocker-compose.ymlper 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 auditas stage 0 before anybuildjob. 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 haveneeds: [audit](anddocker-build-backendadditionallyneeds: [audit, typecheck-backend]—.gitlab-ci.yml:69-89), so theauditgate is blocking — builds never start if auditing fails.- Locally, run
bun auditbefore pushing a release tag. For a quick pre-release check:bun audit --helpandbun pm packfor advisory details. - This gate exists to catch CVEs in the single
bun.lockworkspace 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:
bun --filter @scope/<app> release(orrelease-itdirectly) bumpspackage.json, creates commitchore(release): <app>-x.y.z, tags<app>-x.y.z, pushes..gitlab-ci.ymlhas one job per app, triggered only by matching tag. All jobs are gated byaudit(step 0) — builds only run afterbun auditpasses (.gitlab-ci.yml:69-89needs: [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.The same pattern applies to every frontend and
cdn/website. EveryDockerfilemust useghcr.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
- Init monorepo:
bun init # root package.json: { "private": true, "workspaces": ["packages/*", "apps/*"] } - Add shared:
mkdir -p packages/shared # packages/shared/package.json with "exports" map, no build - 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" } } } - 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 - Wire e2e types: Import
RouterTypefrom@scope/backendinsrc/lib/orpc.tsand create the client. No codegen step needed — TS resolves viaworkspace:*. - Add Dockerfiles per app at
apps/<app>/Dockerfile(file lives here, always build with repo-root contextdocker build -f apps/<app>/Dockerfile .; all Bun stages minimal + zero CVE —ghcr.io/philippdormann/bun:*,nginx:*-alpine*-slim; see §12) +Caddyfile+docker-compose.yml. - Add release-it per app (
tagName: "<app>-${version}") and a CI job per app filtered on^<app>-\d+\.\d+\.\d+$, gated by anauditstage 0 runningbun auditwithneeds: [audit]on every build job (see §12 Security). - Add Postgres as default DB — use Bun's built-in
Bun.SQL(SQLfrom"bun"), no external driver (pg/postgres/mysql2not needed) — see Bun SQL docs. Wire Drizzle viadrizzle-orm/bun-sql(postgres:*-alpine,dialect: "postgresql"— see §9) and avoid MySQL/MariaDB for new code. - Add Valkey for cache/queues (
valkey/valkey:*-alpine, Redis-compatible — see §10): singlevalkeyservice indocker-compose.yml,ioredisclient + BullMQ queues in backend, Valkey distributed lock forcronerjobs. - Add S3 object storage — use Bun's built-in
S3Client(new S3Client()from"bun"), no@aws-sdk/*/minioneeded — see Bun S3 docs and §11. Create centralapps/backend/src/lib/s3.tswithexport const s3 = new S3Client({ bucket: process.env.S3_BUCKET! })and reuse vias3.file()/s3.write()/.presign()(see §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). - Add root scripts:
check,lint:allviabun --filter.
15. Conventions & Gotchas
- Timestamps: Store as UNIX seconds in DB, return UNIX seconds from backend, format in frontend (
DD.MM.YYYY HH:mmdefault;DD.MM.YYYYwithout time;Europe/Berlinfor mails). AvoidDatestrings in the DB. - Asset URLs: Always built in the backend (presigned + cached via Bun
S3Client— see §11). Frontends never interpolate storage paths or constructs3://URLs. - UI components: Prefer shadcn-svelte where available (see §6.5) — do not re-implement
button/card/dialog/table/sonneretc. when the registry provides them. Vendor via CLI, theme viacomponents.json. For class merging usecn(import { cn } from "cn") — notclsx/tailwind-merge/cnfast. - Fonts: Use Fontsource self-hosted (
@fontsource/*viabun 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 bothCORSPlugininstances (RPC + OpenAPI) and fallback 404 handler. - Auth: Validate JWT in middleware, cache resolved user in Valkey (
auth:user:<id>orauth:user:<id>:tenant:<tenantId>for multi-tenant setups), invalidate on permission change. Guard against cross-user-type token reuse by checkingusertype/roleclaims. - OpenAPI docs:
OpenAPIReferencePluginwithscalar+experimental_ValibotToJsonSchemaConverterauto-derives the spec from the sameos.route()definitions — no manual spec. - No
asassertions (exceptas 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/typeofnarrowing, and Valibot inference (v.safeParse+parsed.output,v.InferOutput). Keepstricton. - 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-svelteAlertDialog/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.txtand subpages (canonical reference foros,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 ofclsx/tailwind-merge/cnfastin 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/mysql2needed):https://bun.sh/docs/runtime/sql - Bun S3 (native
S3Client, no@aws-sdk/*needed — centralnew S3Client()per bucket):https://bun.sh/docs/runtime/s3 - Release-it:
https://github.com/release-it/release-it