A self-hosted budget tracker for households. Ask before you buy or record it afterwards, then see where the money went. Designed for phones and run entirely on your own server.
SvelteKit 2 (Svelte 5 runes), Bun, PostgreSQL 17 and Drizzle. Authentication comes from an external Pocket ID instance over OIDC, passkeys only. Deployment is one app container and a database behind your reverse proxy.
Try the live demo, no signup. It runs the real application against Postgres compiled to WASM in the browser tab, over invented seed data.
![]() |
![]() |
![]() |
A walkthrough is coming. The narration script, timed to fit under three minutes, is in docs/demo-script.md. In the meantime the live demo is the real thing and takes no signup.
- Workspaces. Create one or join with an invite code. Owner and member roles, per-member approval policies, and a switcher for people in more than one.
- Gift mode. Hide a purchase from chosen people until a date. It is hidden everywhere: lists, search, detail pages, and every total is recomputed as though it did not exist, so nothing leaks by subtraction. The one path that can auto-approve a sealed purchase says so in the audit log.
- Images. A content-addressed blob store with magic-byte validation, EXIF stripping and WebP derivatives. Originals are discarded.
- Command palette. A local intent parser over spending questions, net position, bucket creation and navigation. No model required.
You need Docker, and a Pocket ID instance for sign-in. Pocket ID is a small OIDC provider that does passkeys; Ledger has no password login of its own, by design.
git clone https://github.com/zenatron/ledger.git
cd ledger
cp .env.example .envEdit .env and set four values:
| Variable | What it is |
|---|---|
PUBLIC_ORIGIN |
The URL people will actually open, e.g. https://ledger.example.com |
POCKET_ID_ISSUER |
Your Pocket ID base URL. No trailing slash, no path. |
POCKET_ID_CLIENT_ID |
From a confidential OIDC client in Pocket ID |
POCKET_ID_CLIENT_SECRET |
The same client's secret |
In Pocket ID, under Administration then OIDC Clients, register the callback
exactly as https://your-host/auth/callback. A mismatch here is the single most
common reason a first login fails.
Then:
docker compose up -d --buildThat starts the app on port 3000 and a Postgres 17 container beside it.
Migrations run automatically on boot. Open PUBLIC_ORIGIN, sign in, and create
a workspace; the account that creates it becomes its owner.
Everything else is optional and off until configured: Web Push, ntfy, basemap
tiles, address search, barcode lookup and the AI assist. .env.example
documents each one and the app works without all of them.
To put it behind a reverse proxy, forward to port 3000 and make sure
PUBLIC_ORIGIN matches the external URL. The app trusts exactly one
X-Forwarded-For hop, so the rate limiter sees real client addresses rather
than your proxy.
git pull
docker compose up -d --buildMigrations are applied on boot behind a Postgres advisory lock, so starting several instances at once is safe. Take a backup first; see Backup and restore.
The app also builds as a static site with no server and no database, for GitHub Pages. It is the same app, not a mock: the real routes, use cases and repositories run against Postgres compiled to WASM in the tab, over a seeded snapshot. Excluded by construction: auth, the AI assistant, MCP, the API, and anything else that needs a backend.
bun run db:start # a local postgres, for building the seed only
bun run demo:seed # seeds a throwaway db, dumps it into a PGlite snapshot
bun run demo:build # generates .demo/routes, builds to build-demo/
bun run demo:preview # both of the above, then serves itThe demo ships the ledger, a purchase's detail and the new-purchase form, buckets, income, analytics, the calendar, the month statement, recurring, categories, appearance and the workspace overview. Left out because they need a backend: auth, Harmony, MCP, the API, reconciliation, the map, CSV export and the notification/member/model settings.
It seeds two workspaces, so switching between them does something. Deleting one lands you on the other; deleting both lands you on the sign-in page, where the demo offers to reseed. Signing out ends the tab's session the way the real one ends a cookie.
demo:seed reuses scripts/seed-workspace.ts unchanged, so the demo's data
cannot drift from the seeder the dev environment uses. demo:build generates a
parallel route tree at .demo/routes rather than touching src/routes; routes
enter the demo by being listed in DEMO_ROUTES in scripts/demo-build.ts, and
only once they have a handlers.ts.
Deploying to a project site (user.github.io/repo) needs the base path:
DEMO_BASE=/repo bun run demo:buildThe demo also gets its own app.html, generated from the real one with the
boot screen in it (src/demo-boot.html and src/demo-boot.css). Its routes
render nothing on the server, so the shell would otherwise be blank paper while
the bundle, the WASM database and the seed arrive; the root layout removes the
element once Svelte mounts. Production renders on the server and never includes
it.
bun run demo:build finishes with scripts/demo-finalize.ts, which copies the
seed and the boot stylesheet in, writes 404.html (GitHub Pages serves it for any path it has no file
for, which is what makes deep links work), adds .nojekyll, and rewrites the
web manifest's scope, start_url and icons for the base path.
.github/workflows/demo.yml builds and publishes to Cloudflare Pages on
every push to main, and on demand from the Actions tab.
The build runs in GitHub Actions rather than Cloudflare's Git integration
because the seed needs a real Postgres to migrate, seed and pg_dump, which a
Pages build container does not provide. Cloudflare only receives the finished
directory, uploaded with Wrangler, so the snapshot is rebuilt from the current
schema every run and never has to be committed.
One-time setup:
- Cloudflare dashboard → Workers & Pages → Create → Pages →
Upload assets, name the project
ledger-demo, and create it. (The first real deploy comes from CI; this only reserves the name.) - Custom domains → add
ledger.pvi.sh. The DNS record is created for you when the zone is already on Cloudflare. - Create an API token with the Cloudflare Pages: Edit permission.
- In GitHub → Settings → Secrets and variables → Actions, add
CLOUDFLARE_API_TOKENandCLOUDFLARE_ACCOUNT_ID.
DEMO_BASE is left empty: the demo is served from the root of its own
subdomain, not a subpath.
DEMO_HOST decides how the client-routed app is served, and the two hosts
disagree in a way that is easy to get backwards:
| fallback | note | |
|---|---|---|
cloudflare (default) |
_redirects with /* /index.html 200 |
a top-level 404.html disables Cloudflare's SPA fallback |
github |
404.html |
GitHub Pages has no rewrite rules |
The workflow runs check and the unit tests before building, so a broken build
cannot replace a working demo. It deliberately does not run the e2e suite: that
needs Postgres, a fake identity provider and browsers, and belongs in its own
workflow.
Note that the demo is public. The seeded data is entirely fictional: the generator invents every name, merchant and amount.
bun install
docker compose up -d db # postgres 17 on :5432
bun scripts/dev-oidc.ts & # fake OIDC provider on :9443 (dev only)
POCKET_ID_ISSUER=http://localhost:9443 \
POCKET_ID_CLIENT_ID=budget-local \
POCKET_ID_CLIENT_SECRET=dev-secret \
OIDC_REDIRECT_URI=http://localhost:5173/auth/callback \
bun run devThe fake IdP auto-approves logins. Switch identities with
curl http://localhost:9443/_as/bob (alice / bob / carol).
bun run test- domain unit tests (vitest)bun run test:e2e- Playwright: approval, sealing and places flows (needs the db container)bun run seed- demo workspace for the fake-IdP usersbun run check- svelte-checkbun run lint/bun run formatbun run db:generate- create a migration after editingsrc/lib/server/db/schema.ts
The images in this file and in the install sheet are committed output. They get regenerated on a redesign, not on every build. The capture happens in two passes, because they need different things:
# 1. Everything the demo can render. Needs only this repo, and is reproducible.
bun run demo:build && bun scripts/capture-screenshots.ts
# 2. The pages that need a server behind them: Harmony, AI assist, members,
# the map, reconcile, API, notifications, and the accent set. Needs a seeded
# database and DEV_MODE.
DEV_MODE=true bun run dev &
CAPTURE_SERVER_WS=<slug> CAPTURE_DB_URL=$DATABASE_URL \
bun scripts/capture-screenshots.ts --server http://localhost:5173CAPTURE_DB_URL is what lets the accent shots write the column the accent
actually lives in. Without it those shots still render, in whatever accent the
workspace already has, and the run says so. The original accent is put back
afterwards, so capturing is not a mutation.
Pass one alone leaves the server-only images untouched and says so. Both write
full-bleed PNGs to static/screenshots/ for the web manifest, and the same
shots with the phone's corner radius to docs/screenshots/ for this file. The
manifest's screenshot list is generated from the same array that drives the
capture, so the two cannot drift.
Migrations run automatically on app boot (single-flight via Postgres advisory lock).
Quick start covers the first boot. .env.example documents the
full environment contract. Beyond that:
- Blobs live in the
blobsvolume at/data/blobs. Back up the database first and the blob directory second. Blobs are content-addressed and append-only, so that order never strands a reference. - Basemap tiles are not a blob.
TILE_CACHE_DIR,/data/tilesby default, holds disposable third-party imagery keyed by coordinate with a 30 day TTL. Leave it out of your backups. It would otherwise carry hundreds of megabytes of somebody else's map into every archive, and deleting it is safe at any time. - The optional self-hosted geocoder is behind a compose profile:
docker compose --profile geocoder up -d. It is not started by default because the first run imports an OpenStreetMap extract, which takes a while and wants real disk. SetNOMINATIM_IMPORT_URLto your own region; the default is a placeholder that finds almost nothing.
# 1. Database first
docker compose exec db pg_dump -U root -Fc local > backup/budget-$(date +%F).dump
# 2. Then blobs (append-only, so dumping after the DB never strands a reference)
docker run --rm -v budget-app_blobs:/data/blobs -v "$PWD/backup:/backup" \
alpine tar czf /backup/blobs-$(date +%F).tgz -C /data blobs
# Restore (reverse order is fine; blobs are content-addressed)
docker compose exec -T db pg_restore -U root -d local --clean < backup/budget-YYYY-MM-DD.dump
docker run --rm -v budget-app_blobs:/data/blobs -v "$PWD/backup:/backup" \
alpine tar xzf /backup/blobs-YYYY-MM-DD.tgz -C /datasrc/lib/domain/ pure TS, no I/O - money, purchase state machine,
approval policy evaluation, staleness, and the location
maths (Web Mercator, bubble clustering, map-link
parsing) - all unit-tested
src/lib/application/ use-cases: create/join workspace, submit/approve/deny/
cancel/complete/edit purchase (transactional + audit event),
recurring materialization, bucket accruals, budget alerts
src/lib/intelligence/ intent parser for the command palette (pure TS, no network)
src/lib/ports/ Clock, IdGenerator, Notifier, BlobStore, LlmAssist,
Geocoder (the last two default to null adapters - the app
is fully usable with neither configured); AppDeps and
AppContext, the composition root's output
src/lib/db/ schema and the `Db` type - the persistence port
src/lib/repo/ repositories over `Db` (every purchase read takes
workspaceId + viewerId); driver-agnostic, so the demo
runs them unchanged against Postgres-in-WASM
src/lib/demo/ the demo build's driven adapters: PGlite, in-memory
blobs, a null notifier, and the browser's context
src/lib/infra/ system clock, UUIDv7, filesystem blob store, image pipeline,
notifiers (web push, ntfy, composite), in-process SSE bus,
geocoding adapters
src/lib/actions/ Svelte actions - money input masking, use:submit, use:dismiss
src/lib/server/ things that genuinely need a server: env validation, the
postgres-js client, migrations, auth (OIDC, sessions),
rate limiting, basemap tile cache, MCP
src/routes/ thin routes; authorization resolved once in hooks.server.ts.
Converted routes keep their logic in a neutral handlers.ts
taking an AppContext, with +page.server.ts a few lines of
binding - the same handlers the demo build runs
The periodic sweep lives in hooks.server.ts: unseal due purchases, materialize
recurring rules and bucket accruals, send stale nudges and budget alerts. It runs
on boot and every 5 minutes, never overlapping itself, and stops on SIGTERM.
Ledger is free, and it stays that way. If it saved you a subscription and you want to put something back, there is a Ko-fi:
Filing a good bug report is worth as much, and costs nothing.
GNU Affero General Public License v3.0 or later.
The network clause is why this license and not a permissive one. If you modify Ledger and run it as a service other people can reach, they are entitled to your changes. Running it unmodified for your own household asks nothing of you.





















