Self-hosted personal finance — accounts, transactions, budgets, and a full investments suite (holdings, options analytics, realized gains/tax lots, dividends, allocation) plus cashflow forecasting and reporting. Next.js 16 + TypeScript, a local SQLite database (Drizzle), and Plaid / Finnhub / Yahoo for data. Everything runs and stays on your machine — the SQLite file never leaves.
Node.js 20.9 or newer is required by Next.js 16. The npm scripts will use a
compatible Homebrew Node installation when an older system Node appears first
on macOS; BUDGETR_NODE_BINARY can point to another compatible executable.
cd web
npm install
npm run setup # creates .env.local, generates an encryption key, migrates + seeds the DB
npm run dev # http://localhost:3000npm run setup is idempotent — re-run it any time. Out of the box the app boots
against Plaid Sandbox, so you can click Connect account and log in with
the test credentials user_good / pass_good to explore with fake data. To link
your real bank, add Plaid credentials (below).
npm run setup copies env.example → .env.local and fills in
APP_ENCRYPTION_KEY for you. The keys that matter:
| Variable | Required | Notes |
|---|---|---|
PLAID_CLIENT_ID / PLAID_SECRET |
to link accounts | From the Plaid dashboard. The app boots without them; you just can't connect a bank. |
PLAID_ENV |
yes | sandbox (default) or production. |
APP_ENCRYPTION_KEY |
yes | 32-byte hex; encrypts Plaid access tokens at rest. Auto-generated by setup. |
DATABASE_PATH |
no | Defaults to ./data/budgetr.db. |
FINNHUB_API_KEY |
no | Enables live intraday prices on Investments; falls back to end-of-day without it. |
PLAID_PRODUCTS |
no | Required Link products (default transactions). Link only shows institutions supporting all of these. |
PLAID_OPTIONAL_PRODUCTS |
no | Pulled when available (default investments) but never block linking. |
PLAID_COUNTRY_CODES |
no | Default US. |
ATTACHMENTS_DIR |
no | Receipt storage; defaults to ./data/attachments. |
Plaid access tokens are environment-scoped — a sandbox token is invalid in production and vice versa — so switching means re-linking, not flipping a flag:
- Request production access in the Plaid dashboard and grab the production
PLAID_SECRET. - In
.env.local: setPLAID_ENV=productionand swap in the production secret. npm run db:reset-itemsto drop sandbox links, then restart and re-link from Accounts → Connect account.
| Command | What it does |
|---|---|
npm run dev |
Dev server at localhost:3000. |
npm run build / npm run start |
Production build / serve. |
npm run setup |
First-run bootstrap (env + key + migrate + seed). |
npm test / npm run test:watch |
Run / watch the Vitest unit tests. |
npm run lint |
ESLint. |
npm run sync |
Pull the latest transactions/holdings/balances from Plaid into the DB. |
npm run db:generate |
Generate a Drizzle migration after editing db/schema.ts. |
npm run db:migrate |
Apply pending migrations. |
npm run db:seed |
Seed the default categories (idempotent). |
npm run db:studio |
Open Drizzle Studio to browse the DB. |
Pure business logic is unit-tested with Vitest — colocated
lib/*.test.ts files covering the trickiest deterministic modules: recurring
cashflow projection, option-symbol parsing/risk, Black-Scholes greeks, realized
tax lots (FIFO/LIFO/wash-sale), and asset allocation.
npm testDB-backed query and action layers are intentionally kept thin over these pure modules, which is where the test value concentrates.
app/ Next.js App Router pages (server components) + route handlers
components/ Client components (charts, tables, editors, dialogs)
lib/ Business logic — queries.ts (reads), actions.ts (mutations),
and pure modules (forecast, recurrence, options, tax-lots, …)
db/ Drizzle schema + generated SQL migrations
scripts/ setup, seed, sync, and maintenance scripts
Data flows one way: server-component pages call synchronous lib/queries.ts
functions (better-sqlite3 is sync); mutations are Server Actions in
lib/actions.ts that write to SQLite and revalidatePath("/", "layout").
cd web
cp env.example .env.local # fill in PLAID_* and set APP_ENCRYPTION_KEY (openssl rand -hex 32)
docker compose up -d # http://localhost:3000The SQLite database and receipt attachments live in the budgetr-data volume
(/data in the container). Migrations run automatically on start, so upgrading
is git pull && docker compose up -d --build.
Warning: the app has no authentication — it's designed for one person on a trusted machine/network. If you expose it beyond localhost, put it behind a reverse proxy with auth (Caddy
basic_auth, Tailscale, Authelia, etc.).
desktop/ contains a launchd + Caddy setup that runs
next start as a login service behind a local hostname (see
desktop/README.md), and an Electron packaging target
(npm run package). These are optional — npm run dev is all you need to work
on the app.
Packaged (Electron) builds never bundle your .env.local. They read secrets
from a per-user file — ~/Library/Application Support/budgetr/budgetr.env —
which is auto-created on first launch with a fresh APP_ENCRYPTION_KEY; add
your Plaid/Finnhub keys there and relaunch. In the app, Settings → Open
Settings File (⌘,) opens it directly and Settings → Relaunch to Apply
Settings restarts. This is what makes a publicly distributed DMG safe: no
shared encryption key, no baked-in credentials.