Architecture
How OpenCount is put together, and why.
#One container, two processes, one file
The image runs two compiled Bun binaries:
- The server: the SvelteKit app, compiled by
@sveltejs/adapter-bunwith the Bun runtime embedded. It serves pages, form actions and better-auth's API. - The worker: background jobs. The server spawns it at boot, sends it its configuration over IPC, and restarts it with backoff when it exits. The worker watches its parent in turn and exits when the server is gone, so neither outlives the other.
Both open the same SQLite file through bun:sqlite, in WAL mode with foreign
keys on and a busy timeout, so the server's reads never wait on the worker's
writes. There is no Redis, no Postgres, no message broker: a self-hosted app for
a few dozen people doesn't need them, and every extra service is one more thing
to back up and upgrade.
#Boot
- The server reads and validates its environment into one
Config. An invalid variable stops it with the variable's name. - It checks the migration history, then applies pending migrations. A failure stops the boot.
- On a fresh instance, it prints a setup code.
- It starts the worker, which waits for its
Configover IPC (the worker never reads its own environment), requeues jobs a previous worker left running, and schedules the daily jobs if they aren't queued.
#Jobs
A job is a row in the job table: a type, a typed payload, a due time and an
attempt count. The worker polls for due jobs every WORKER_POLL_INTERVAL_MS,
claims up to WORKER_CONCURRENCY at once, and runs each in a job <type> span.
A failure is retried with exponential backoff (5 seconds, doubling) up to five
attempts, then marked failed and shown on the admin overview.
| Job | What it does |
|---|---|
email.send | Delivers one email through the configured SMTP server |
recurring.run | Adds due repeating expenses, then requeues itself for the next day at 00:05 UTC |
jobs.prune | Deletes finished jobs older than 7 days and downloads older than a day, then requeues itself a day later |
download.build | Builds a database backup, or a group's CSV or PDF export, into a file waiting for its owner |
bank.syncAll | Queues a bank.sync for every linked bank, then requeues itself a day later |
bank.sync | Fetches one link's new booked debits into the personal ledger, then sends a push notification |
import.tricount | Adds the expenses of a previewed Tricount to a group, then sends a push notification |
Built files (exports and backups) live in a downloads folder next to the
database, and are served only to the account that asked for them.
Requests enqueue and return; they never await a job. Receipt reading is the one slow thing done in a request, on purpose: the person is waiting for the reading to fill the form, and the file must not be stored to hand it to another process.
#Requests
hooks.server.ts does four things and nothing else: log the request, check the
origin of form posts, resolve the session into locals, and hand better-auth
its own API paths. Redirects and access rules live in layouts and form actions:
- Every signed-in page sits under one route group whose layout sends signed-out visitors to sign-in, and anyone missing a required factor to their security page.
- The admin layout 404s non-admins, and every admin action re-checks the role.
- Every group route resolves the group through a membership check before reading anything.
#Data
Each table has a DTO class that owns its queries; routes never touch Drizzle
directly and send .toJSON() to the page. Money is an integer of minor units
next to the group's currency code. Balances and insights are computed from the
expenses on every load rather than stored, so they can't drift.
A settlement is an expense of kind transfer from the payer to the receiver.
Balances count it like any expense; insights leave it out.
#Authentication
better-auth over the same SQLite database, with email and password, TOTP
two-factor, passkeys, and OpenID Connect providers through its generic OAuth
plugin. Its public sign-up endpoint is disabled: accounts only come from the
setup flow, the sign-up stepper, invites or single sign-on with sign-ups open.
The role column is declared with input: false, so no request can set it.
Instance settings, single sign-on providers included, live in a setting table.
Changing the providers or the sign-up policy rebuilds the better-auth instance
on the next request. Secrets in settings are encrypted with AES-256-GCM under
keys derived from AUTH_SECRET by HKDF.
#Client
Svelte 5 runes, Tailwind v4 and shadcn-svelte components. Every async action goes through a toast that shows its progress and its outcome. Navigation is a bottom tab bar on phones and a sidebar on desktop; sheets are drawers on phones and dialogs on desktop.
Appearance is applied as data attributes on <html> by an inline script before
first paint. Every colour is a token derived from one --hue, so an accent
change is one attribute.
A service worker precaches the built assets, so an installed app starts
instantly. Pages and their data are fetched from the server first and each
answer is kept per app version (never /api/, /auth/ or /downloads/), so a
page already visited reopens with no connection; an unvisited one gets a static
offline page. Signing out wipes that cache. A new expense submitted offline is
kept in IndexedDB with a client id and posted when the connection returns, so a
retry never adds it twice.
#Telemetry
OpenTelemetry tracing (SvelteKit's server tracing plus job spans) over OTLP/HTTP, and Sentry for errors, both off unless configured. See Logs, traces and errors.
This guide lives in the project repo: edit it there, and this page follows within a day.