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 · 31.9 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 (oven/bun:1.4-alpine) Fast install/start, native Bun.serve, Bun.file/Bun.write
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 Runes, adapter-static (SPA) or adapter-node (SSR)
DB ORM Drizzle ORM (drizzle-orm + drizzle-kit) on Postgres (drizzle-orm/bun-sql) Typed SQL via bun-sqlPostgres is the preferred DB; MySQL/MariaDB is legacy/avoid for new code
Cache / Queues Valkey + BullMQ + croner Sessions, rate-limits, scheduled jobs
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 alpine/scratch 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, 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-31appId: "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 §12).

6.2 Vite + SvelteKit Config

  • SPA frontendsadapter-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: oven/bun:1.4.2-alpinebun run buildnginx:1.29-alpine3.23-slim (alpine) serving build/ with nginx.conf that does try_files $uri /app.html and long-cache on /_app.

  • SSR websiteadapter-node running build/index.js on PORT=80. Build + runtime both use oven/bun:1.4.2-alpine (apps/website/Dockerfile:1,34) — prefer alpine/scratch everywhere (see §11 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.

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.).
  • Keep server-only code out of shared.

8. Styling / Linting / Formatting

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.

  • Single Drizzle client in apps/backend/src/db.ts:1-25:

    import { SQL } from "bun";
    import { drizzle } from "drizzle-orm/bun-sql/postgres"; // Postgres-only path
    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`); }
    
  • 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.

11. Deployment

Docker — Alpine / Scratch Only

Policy: Use alpine or scratch based images wherever possible. All Dockerfiles in this repo follow this — do not introduce 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 oven/bun:1.4.2-alpine (apps/backend/Dockerfile:1), bun install --filter @scope/<app> --production, CMD ["bun","run","src/index.ts"].
  • Frontends (static): multi-stage oven/bun:1.4.2-alpine 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 oven/bun:1.4.2-alpine build → oven/bun:1.4.2-alpine 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-alpine 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: oven/bun:1.4.2-alpine    # alpine only (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.

12. 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: oven/bun:1.4.2-alpine
      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 be alpine/scratch based (see §11 Docker policy); non-alpine 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.


13. 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
    # 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"]
    
  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 alpine/scratch based — oven/bun:*-alpine, nginx:*-alpine*-slim; see §11) + 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 §11 Security).
  8. Add Postgres as default DB (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 Capacitor integration for native shells if needed: capacitor.config.ts (webDir: "build"), bunx cap sync, version sync scripts, plugins via @capacitor/* (see §6.0).
  11. Add root scripts: check, lint:all via bun --filter.

14. 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). Frontends never interpolate storage paths.
  • 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.
  • Avoid window.alert/confirm: Use proper modals.

15. 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
  • Drizzle ORM: https://orm.drizzle.team
  • Release-it: https://github.com/release-it/release-it