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 (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-sql — Postgres 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(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, 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 §12).
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:
oven/bun:1.4.2-alpine→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 useoven/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
#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.
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.).
- 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 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.
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 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.
11. Deployment
Docker — Alpine / Scratch Only
Policy: Use
alpineorscratchbased images wherever possible. All Dockerfiles in this repo follow this — do not introducedebian/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
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-alpinebuild →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
oven/bun:1.4.2-alpinebuild →oven/bun:1.4.2-alpineruntime (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-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 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: 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 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.
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:
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: 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.The same pattern applies to every frontend and
cdn/website. EveryDockerfilemust 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
- 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 # 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"] - 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 .; allalpine/scratchbased —oven/bun:*-alpine,nginx:*-alpine*-slim; see §11) +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 §11 Security). - Add Postgres as default DB (
drizzle-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 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.
14. 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). Frontends never interpolate storage paths.
- 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. - Avoid
window.alert/confirm: Use proper modals.
15. 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 - Drizzle ORM:
https://orm.drizzle.team - Release-it:
https://github.com/release-it/release-it