Self-hosted personal budgeting app built with Next.js App Router, TypeScript, Tailwind CSS, SQLite, and cookie sessions.
The app supports up to 3 budget plans per user and keeps user state isolated by account.
- Username/password auth with server-side sessions.
- Optional dev login shortcut outside production.
- Multi-plan support:
- up to 3 plans per user
- switch, create, rename, and delete plans
- dedicated plans management page
- Budget editor with:
- yearly salary
- bonus (none, fixed amount, or % of salary)
- expenses (monthly or bi-weekly frequency)
- investments:
tfsa,fhsa,rrsp,emergencyFund(monthly or bi-weekly per bucket)
- Autosave (~800ms debounce) to SQLite state for the active plan.
- Monthly summary: net income, total expenses, total investments, leftover cash.
- Expanded summary popup: allocation percentages, coverage ratio, and 12-month projections.
- Ontario 2026 income tax/deduction model used for monthly net income.
- Next.js 16 App Router (single service)
- React 19 + TypeScript
- Tailwind CSS
- SQLite via
better-sqlite3 bcryptjspassword hashing- Cookie sessions (
HttpOnly,SameSite=Lax)
- Install dependencies:
npm ci
- Start dev server:
npm run dev
- Open:
http://localhost:4050
Notes:
Sign Upis enabled only whenALLOW_SIGNUP=true.Continue as Dev Useris available only when:NODE_ENV !== "production"DEV_LOGIN_ENABLED !== "false"
DEV_LOGIN_USERNAMEdefaults todev-userand must match^[a-z0-9_-]{3,32}$.
Run:
docker compose up --build -dCurrent production-oriented defaults:
NODE_ENV: productionPORT: 4050HOSTNAME: 0.0.0.0DATABASE_PATH: /data/budget.dbALLOW_SIGNUP: "false"SECURE_COOKIES: "true"(expects HTTPS)- Rate limits enabled for all
/api/* - Host bind:
127.0.0.1:4050:4050
Run:
docker compose -f docker-compose-dev.yml up --build -dCurrent defaults:
NODE_ENV: productionALLOW_SIGNUP: "true"SECURE_COOKIES: "false"- Host bind:
4050:4050 - Uses host-native container architecture by default (faster on Windows/x86_64 and ARM hosts).
Important:
- Because this file sets
NODE_ENV: production, dev login is disabled in this mode. - If you need to force ARM images on non-ARM hosts, set
DOCKER_DEFAULT_PLATFORM=linux/arm64before running compose.
PORT(default4050)HOSTNAME(default0.0.0.0)DATABASE_PATH(default/data/budget.db, fallback./data/budget.dbif directory creation fails)ALLOW_SIGNUP("true"or"false")SECURE_COOKIES("true"or"false")DEV_LOGIN_ENABLED("false"disables dev login outside production)DEV_LOGIN_USERNAME(defaultdev-user)API_RATE_LIMIT_WINDOW_MS(default60000, clamped)API_RATE_LIMIT_GENERAL_MAX(default120, clamped)API_RATE_LIMIT_AUTH_MAX(default15, clamped)
- Session cookie:
budget_session - Session TTL: 30 days
- Session storage table:
sessions - Expired session cleanup runs in-process periodically during requests
- Mutating auth/state/plan routes enforce origin/host match (
OriginvsHost/X-Forwarded-Host)
POST /api/auth/signup- Requires
ALLOW_SIGNUP === "true" - Username:
^[a-z0-9_-]{3,32}$ - Password length:
8-128
- Requires
POST /api/auth/loginPOST /api/auth/logoutPOST /api/auth/dev-login(non-production only, if enabled)GET /api/me-> returns{ username }when authenticatedGET /api/state-> returns state for active plan and plan metadataPUT /api/state-> sanitizes and saves full current state for active planGET /api/plans-> returns plans list, active plan id, max plansPOST /api/plans-> creates a new default-state plan and makes it activePOST /api/plans/switch-> sets active plan for current sessionPATCH /api/plans/:planId-> renames planDELETE /api/plans/:planId-> deletes plan (cannot delete last remaining plan)
Server-side sanitization happens in lib/budget-state.ts.
Limits:
yearlySalary <= 500000- bonus amount
<= 100000 - bonus percent
<= 100 - each expense amount
<= 10000 - each investment bucket (
tfsa,fhsa,rrsp,emergencyFund)<= 10000
Frequency values:
monthlybi-weekly(converted to monthly equivalent for totals)
Tables:
users(id, username UNIQUE, password_hash, created_at)plans(id, user_id, name, created_at, updated_at)plan_states(plan_id PRIMARY KEY, state_json, updated_at)sessions(id, user_id, token_hash UNIQUE, active_plan_id, expires_at, created_at)
State persistence:
- one JSON snapshot per plan in
plan_states.state_json - active plan stored per session in
sessions.active_plan_id - overwrite-on-save via upsert for active plan
Applied by proxy.ts on /api/:path*.
Headers:
Cache-Control: no-storeX-Content-Type-Options: nosniffX-Frame-Options: DENYReferrer-Policy: same-originContent-Security-Policy: default-src 'none'; base-uri 'none'; frame-ancestors 'none'; form-action 'self'Permissions-Policy(sensitive browser APIs disabled)Cross-Origin-Opener-Policy: same-originCross-Origin-Resource-Policy: same-originX-DNS-Prefetch-Control: offX-RateLimit-LimitX-RateLimit-RemainingX-RateLimit-ResetRetry-Afteron429
Default limits:
/api/auth/*: 15 requests / 60 seconds / client IP- other
/api/*: 120 requests / 60 seconds / client IP
Back up /data/budget.db from the volume/container.
Examples:
docker compose exec budget-app sh -c "cp /data/budget.db /data/budget-backup.db"docker cp <container_id>:/data/budget.db ./budget.dbTo change host port, update:
portsmapping in composePORTenvironment value if container port changes
Example (host 5050 -> container 4050):
environment:
PORT: 4050
ports:
- "127.0.0.1:5050:4050"Then rebuild/restart:
docker compose up -d --buildnpm run dev->next dev -p 4050npm run build->next buildnpm run start->next start -p 4050npm run lint->eslint .
- No automated test suite is configured yet.
