Self-hosted personal finance. Your accounts, cash flow, budget, net worth, and debts — in one SQLite file on your own machine, with no third party holding your data.
A coffer is the strongbox a household kept its money in.
Built for Canadian banking, where no single aggregator covers everything: Plaid handles the banks it can, a driven Chrome window exports CSVs from the banks it can't, and manual CSV import covers the rest. Everything lands in one deduplicated ledger.
Coffer is self-contained: a FastAPI backend, a static web bundle, and a SQLite file. No cloud services, no telemetry, no account signup — the only outbound calls are to Plaid (if you opt in) and to your banks (when you sync).
backend/ FastAPI + SQLModel + SQLite + Alembic (Python 3.12, uv)
web/ SvelteKit (Svelte 5) + Tailwind v4 + LayerChart (Node 20+, pnpm)
- Dashboard — balances, recent activity, top spending at a glance.
- Cash flow — income vs. spending by month; drill into any category or merchant. Reports by the date you actually spent, not the posted date.
- Budget — month-to-date budget with targets auto-seeded from your own trailing spending, planned against projected income.
- Net worth — assets and liabilities over time, per-account history.
- Debts — every liability with its trajectory.
- Rules — auto-categorize and rename merchants. Manual edits lock the transaction; no sync or rule ever clobbers them.
- Transfers — internal moves are flagged and excluded from spending.
- Local-first — one SQLite file under
backend/data/. No cloud, no telemetry, no accounts. Delete the folder and it never existed.
| Path | Good for | Effort per sync |
|---|---|---|
| Plaid | Banks Plaid supports (e.g. TD, Amex) | Zero — one click |
| Browser sync | Banks Plaid can't hold (PC Financial, RBC) | ~30 seconds — you approve the login, the driven Chrome window does the rest |
| CSV import | Anything with an export (IBKR Flex Query, …) | Download + upload, with column mapping and preview |
| Manual accounts | Balance-only assets (pensions, property) | Update the balance when you feel like it |
All import paths feed the same deduplicating importer, so overlapping date ranges are free, and every CSV import can be undone as a unit.
| Cash flow | Budget |
|---|---|
![]() |
![]() |
| Net worth | Transactions |
|---|---|
![]() |
![]() |
- uv — fetches a managed Python 3.12 on first sync if you don't have one
- Node 20+ and pnpm (
npm install -g pnpm) - Google Chrome — only if you enable browser sync (Playwright drives your installed Chrome; no separate browser download)
Windows PowerShell 5.1: run the snippets one line at a time —
&&doesn't chain there. Ifuvorpnpmsay "not recognized" right after installing, reopen the terminal so PATH refreshes.
.\coffer.ps1First run does everything: installs backend deps, creates the database,
copies .env.example to .env, installs web deps, builds the bundle, and
serves the whole app on http://localhost:51823. Later runs just start it
(rebuilding the bundle only when sources changed). .\coffer.ps1 -Dev gives
hot-reload development instead.
cd backend
cp .env.example .env
uv sync # creates .venv, installs deps
uv run alembic upgrade head # creates data/finance.db
cd ../web
pnpm install
pnpm build
# serve everything from one port
cd ../backend
uv run uvicorn finance.main:app --host 0.0.0.0 --port 51823For development, run the backend with --reload in one terminal and pnpm dev
in another, then use http://localhost:5174 (proxies /api to the backend).
You can run without touching .env at all — Plaid and browser sync stay off
until configured, and manual accounts + CSV import work out of the box.
cd backend
uv run python scripts/seed_demo.pySeeds seven months of realistic fake data — six accounts, ~300 transactions,
balance history — so every screen has something to show (it's what the
screenshots above are). It refuses to touch a database that already has real
data; to clear it, stop the server and delete data/finance.db*.
First real run: add institutions and accounts under Accounts, then bring in transactions by any path below. Default categories are seeded automatically.
Plaid's Trial Plan is free with fast approval:
- Sign up at https://dashboard.plaid.com/signup and pick the Trial Plan.
- Team Settings → Keys — copy
client_idandsecretintobackend/.env(PLAID_ENV=sandboxto test,productionfor real banks). - Connecting an OAuth bank from a non-localhost address additionally needs an
Allowed redirect URI in the Plaid dashboard and
PLAID_REDIRECT_URIin.env, e.g.http://your-machine.your-tailnet.ts.net:51823/oauth-redirect.
Then Accounts → Connect a bank in the UI, and Sync now whenever you want fresh transactions.
Some banks won't hold a Plaid connection — RBC's MFA method, for example,
isn't supported at all. For these, Sync now opens a real, visible Chrome
window on the bank's site: Chrome autofills the saved password, you approve
the MFA challenge, and the app clicks through to the CSV export and imports
it. The window runs from a dedicated Chrome profile in backend/data/ —
never your daily browser profile — and that profile's saved password and
"remember this device" cookie are what make later syncs quick.
One-time warm-up per bank:
cd backend
uv run python scripts/pc_walkthrough.py # PC Financial
uv run python scripts/rbc_walkthrough.py # RBCLog in once, let Chrome save the password, tick "remember this device". Then
set the matching *_SYNC_ENABLED and *_ACCOUNT_ID lines in .env (for RBC
also RBC_ACCOUNT_LAST4 / RBC_ACCOUNT_KEY) — each is documented in
.env.example. If the bank redesigns its site, the sync
degrades gracefully: the window stays open and whatever CSV you download by
hand gets imported.
Note: automated access to online banking may be against your bank's terms of service. Coffer drives your own browser, against your own accounts, on your own machine — but read your bank's terms and decide for yourself.
Imports accepts any transactions CSV: the wizard sniffs the date, description, and amount columns (including debit/credit pairs), shows a preview, and lets you fix the mapping before committing. Re-importing an overlapping export is safe — duplicates are skipped — and every import can be undone from the past-imports list.
Everything a Sync now click does is one API call, so any scheduler works:
curl -X POST http://localhost:51823/api/sync \
-H "Content-Type: application/json" -d "{}"Put that in Windows Task Scheduler or cron for a daily Plaid pull. One caveat:
if browser sync is enabled, a global sync also opens the Chrome window, and a
bank that challenges for MFA needs you at the machine — so either schedule it
for a time you're around, or keep browser-synced banks on manual Sync now
and let the schedule cover Plaid. Sync results land in /api/sync/logs and on
the accounts screen either way.
Coffer has no authentication — it's a single-household app that trusts its
network. Keep it on localhost or a private network like
Tailscale; never port-forward it to the internet.
With Tailscale on both devices, open http://<your-machine>:51823 on your
phone and tap Add to Home Screen — the PWA manifest is set up, so it
installs as a standalone app with its own icon, indistinguishable from a
native one. CORS allows http://localhost:* and the Tailscale CGNAT range
out of the box.
Sharing with a partner is the same trick: invite them to your tailnet (Tailscale's free plan covers a household), install Tailscale on their phone, and they get the same URL and the same home-screen app. Everyone on the tailnet sees the same books — there are no per-person accounts, which for a shared household budget is the point.
To keep the backend running in the background on Windows:
pm2 start ecosystem.config.cjs.
- Everything lives in
backend/data/: the SQLite database, backups, and the automation Chrome profile. The whole directory is gitignored. - The Chrome automation profile contains your saved bank passwords. Treat
backend/data/like a password vault: keep it out of cloud-synced folders and shared backups. - Plaid credentials stay in
backend/.env(gitignored). - No telemetry, no analytics, no outbound calls except to Plaid (if configured) and your banks (when you sync).
Backup is one file:
sqlite3 backend/data/finance.db ".backup 'backups/finance-2026-07-09.db'"Restore by copying the file back. There is no other state.
backend/
src/finance/
routes/ dashboard, cash-flow, budget, net-worth, debts, transactions,
accounts, categories, rules, imports, sync, plaid
services/ plaid_client, sync, csv_import (dedup core), categorize,
rules, pc_financial_sync, rbc_sync, browser_profile
models.py SQLModel tables — money as signed integer cents, dates UTC
seed.py default categories on first boot
config.py pydantic-settings; everything overridable via .env
migrations/ Alembic
scripts/ bank walkthroughs + one-off utilities
web/
src/routes/ one folder per screen
src/lib/ typed API client, formatting, components, charts
Design invariants worth knowing before you patch anything:
- Money is signed integer cents, positive = inflow. Plaid's opposite sign convention is inverted at the sync boundary.
- Datetimes are stored UTC; day boundaries ("today", budget month-to-date)
use the
TIMEZONEsetting. - User edits are never overwritten by syncs or rules. Soft delete only.
cd backend
uv run ruff check . # lint
uv run ruff format . # format
uv run alembic revision --autogenerate -m "..." # new migration
cd web
pnpm check # svelte-check
pnpm lint # prettier + eslint- Generic assisted browser sync: point it at any bank's login URL, download the CSV yourself, let the importer catch the file — no per-bank code.
- Recordable per-bank export recipes (JSON steps) instead of hardcoded selectors, shareable as community files.
- In-app settings and first-run onboarding instead of
.envediting. - One-command setup for macOS/Linux, and a Docker image covering the Plaid + CSV paths (browser sync needs a visible Chrome, so it stays native).
- Built-in sync scheduler with per-institution cadence.
- Configurable currency/locale (currently formats as CAD).





