Project architecture

Overview of the Penombre project architecture

Overview

Penombre is structured as a Bun monorepo with three packages:

Web

SvelteKit full-stack app (frontend + REST API).

Mobile

Expo + React Native cross-platform app.

Docs

Next.js + Fumadocs documentation site.

penombre/
├── packages/
│   ├── web/       # SvelteKit — frontend + REST API + database
│   ├── mobile/    # Expo — React Native client
│   └── docs/      # Next.js — documentation
├── scripts/       # Shared tooling (API codegen, DB diagram, circular checks)
├── Dockerfile     # Multi-stage production build
└── compose.yaml   # Docker Compose (app + PostgreSQL)

Web package

The web package is the core of Penombre — a SvelteKit application that serves both the frontend UI and the backend REST API.

Tech stack

LayerTechnology
FrameworkSvelteKit + Svelte 5
RuntimeBun (via svelte-adapter-bun)
StylingTailwindCSS 4, shadcn-svelte (bits-ui)
ORMDrizzle ORM on PostgreSQL
AuthBetter Auth (email/password, OAuth, passkeys, API keys)
i18nParaglide-JS (English + French)
Formssveltekit-superforms + Valibot
APIOpenAPI-first with Zod schemas

Backend architecture

src/lib/server/
├── openapi/v1/    # API schema definitions (Zod + defineRoute)
├── services/      # Business logic
│   ├── storage/   # File/folder CRUD, caching, thumbnails
│   ├── activity.ts
│   ├── preferences.ts
│   └── user.ts
├── auth/          # Better Auth setup & seeding
├── db/            # Drizzle schema & connection
├── config.ts      # Runtime configuration (Zod-validated)
├── errors.ts      # Custom error classes
└── http.ts        # Standardized HTTP response helpers

OpenAPI-first API

API routes follow the thin handler pattern. Schemas are defined in src/lib/server/openapi/v1/ using defineRoute() with Zod, and route handlers in src/routes/api/v1/ delegate all business logic to the service layer.

// Route handler — thin wrapper
export const POST = createFile.handler(async ({ body, query, service }) => {
    const res = await service.createFile(body, query.folder);
    return Http.Ok(res);
});

All API responses use a standardized format via Http.* helpers:

{ "data": { ... }, "message": "..." }

Service layer

Services encapsulate business logic and are instantiated per-request with the authenticated user context. The main services are:

  • StorageService — file/folder CRUD, uploads, downloads, sharing, metadata caching
  • ActivityService — audit logging (create, update, delete, share, rename actions)
  • PreferencesService — user layout and sorting settings
  • UserService — profile management

Authentication

Better Auth handles authentication with the following plugins:

  • Email/password — traditional sign-in with optional email verification via SMTP
  • OAuth — any OIDC-compliant provider (Google, GitHub, Pocket ID, etc.)
  • Passkeys — WebAuthn/FIDO2 passwordless authentication
  • API keys — rate-limited keys for programmatic access
  • Expo — mobile app deep-link authentication

On startup, the server hook (hooks.server.ts) waits for the database, runs Drizzle migrations, and seeds the initial admin account if no users exist.

Configuration

Runtime configuration is loaded from environment variables and validated with Zod at startup. Defaults are defined in config.defaults.ts. See Environment variables for the full reference.

Frontend architecture

src/lib/
├── components/
│   ├── ui/           # 40+ shadcn-svelte components
│   └── ...           # File views (grid, list, table), dialogs, etc.
├── store/            # Svelte stores
│   ├── actions.ts    # File/folder action state
│   ├── music.ts      # Audio player state
│   ├── sorting.ts    # Layout & sorting preferences
│   ├── title.ts      # Page title
│   └── upload.ts     # Upload progress tracking
├── schemas/          # Form validation (sveltekit-superforms)
├── paraglide/        # Generated i18n (en/fr)
└── hooks/            # Client-side hooks

The frontend uses Svelte 5 runes ($state, $derived, $effect) for reactivity and shadcn-svelte for the component library. All user-facing strings go through Paraglide-JS for internationalization.

Mobile package

An Expo + React Native app that connects to a Penombre instance.

Tech stack

LayerTechnology
RuntimeExpo 55, React Native 0.81
RoutingExpo Router (file-based)
StylingNativeWind (TailwindCSS for RN)
Data fetchingSWR
API clientopenapi-fetch (shared types with web)
AuthBetter Auth Expo plugin + SecureStore

The mobile app authenticates via deep links (penombre:// scheme) and stores credentials in encrypted device storage using Expo SecureStore. API types are shared with the web package through the OpenAPI code generation pipeline.

Docs package

A static documentation site built with Next.js 16 and Fumadocs, exported as static HTML for deployment anywhere.

Content lives in MDX files under content/docs/. Full-text search is powered by Orama.

Database

Penombre uses PostgreSQL with Drizzle ORM. Migrations are auto-generated via drizzle-kit generate and applied on startup.

Database diagram

Run bun run db:diagram from the project root to regenerate this diagram.

Infrastructure

Docker

The production Docker image uses a multi-stage build:

  1. base — Alpine + Bun runtime
  2. builder — Install dependencies with frozen lockfile
  3. frontend-builder — Build the SvelteKit app with Vite
  4. final — Minimal runtime with ffmpeg and poppler-utils for media thumbnails

The final image runs as a non-root bun user with a health check on /api/health.

Docker Compose

The default compose.yaml defines two services:

  • app — the Penombre web server on port 3000, with a persistent /data volume for file storage
  • db — PostgreSQL 17 Alpine with a health check and persistent data volume

Shared scripts

ScriptPurpose
gen-api.tsGenerate OpenAPI types for web and mobile from the API spec
db-diagram.tsGenerate the database diagram (DBML → SVG)
circular.tsDetect circular dependencies via madge
backup.shBack up the PostgreSQL database
restore.shRestore a PostgreSQL database backup

Found an issue or want to contribute? Edit this page on GitHub

On this page