Finans is a self-hosted, single-user budgeting app with European bank integration, built as a replacement for YNAB. Money that comes in lands in Ready to Assign, and you give every krone a job by assigning it to categories. Transactions are pulled automatically from your bank, categorized by rules, and reconciled against your real balances.
Heads up — read before you self-host
- The UI is in Norwegian and the app currently supports NOK only. Bank connectivity works across Europe (the aggregators below cover most of the EEA), but the interface and currency are not localized yet.
- It's a single-user app secured by one shared password — there is no multi-user account system. Run it for yourself (or a household), behind HTTPS.
- This is a personal project shared in the hope it's useful. It comes with no warranty (see LICENSE). You are responsible for your own deployment, backups, and the bank credentials you configure.
- Manual + automatic accounting — cash, bank, savings, credit card and loan accounts; on-budget vs. tracking accounts.
- Envelope budgeting engine — category groups & categories, monthly assignment, activity/available, and a leak-free Ready to Assign.
- Goals & auto-assign — monthly funding, "have X available each month", and save-up-to-an-amount-by-a-date targets.
- Bank integration behind a provider-agnostic abstraction: GoCardless Bank Account Data and Enable Banking (use either or both).
- Auto-categorization rules — derive payee/memo and a target (a category, Ready to Assign, or a transfer) from the bank's description; most specific rule wins.
- Scheduled / recurring transactions (bills), including scheduled transfers.
- Credit cards as ordinary accounts that can go negative; pay them down with a transfer.
- Reconciliation against your real bank balance.
- Split transactions across multiple categories.
- Reports — spending by category, income vs. spending, category trends, net worth (Recharts).
- Nightly sync (queued job + scheduler) with an email report, plus consent-expiry warnings and one-click renewal.
- Mobile quick-entry screen for jotting down cash transactions on the go.
| Layer | Technology |
|---|---|
| Backend | Laravel 12 (PHP 8.2+) as a JSON API |
| Frontend | React 19 + TypeScript + React Router 7, built with Vite; Tailwind 4; Recharts |
| Database | SQLite locally, MySQL/MariaDB in production (migrations are DB-agnostic) |
| Auth | Single app-wide password; 1-year session cookie |
| Queue / cache | database driver — no Redis required |
Frontend and backend are the same Laravel app on the same origin: the React
SPA is served by a catch-all Blade route, and the API lives under /api/* with
session + CSRF protection.
- PHP 8.2+ (developed and tested on 8.4) with the usual Laravel extensions
(
mbstring,openssl,pdo,tokenizer,xml,ctype,json,bcmath,fileinfo).opensslis required for Enable Banking's JWT signing. - Composer 2
- Node.js 20+ and npm (for building the frontend with Vite 7)
- A database: SQLite is enough locally; MySQL/MariaDB recommended for production.
- For automatic bank sync in production: a persistent queue worker and the Laravel scheduler. The bundled Docker setup runs both as their own containers — no cron or Supervisor on the host. See Production deployment.
- A free account with at least one bank aggregator — GoCardless Bank Account Data or Enable Banking. See docs/bank-setup.md.
git clone https://github.com/rholthe/finans.git
cd finans
composer install
npm install
cp .env.example .env
php artisan key:generateFor the simplest local setup, use SQLite. Edit .env:
DB_CONNECTION=sqlite
# remove or comment out DB_HOST / DB_PORT / DB_DATABASE / DB_USERNAME / DB_PASSWORDtouch database/database.sqlite
php artisan migrate
# Set the single app password (you'll log in with this)
php artisan app:set-password "choose-a-strong-password"
# Run server + Vite + queue worker + log tailer together
composer devOpen the URL printed by php artisan serve (default http://localhost:8000)
and log in with the password you set.
Why
composer devand not justphp artisan serve? Bank sync runs as a queued job.composer devstarts a queue worker alongside the server, so sync jobs actually run. With a bareservethey would sit in the queue.
Run the test suite with:
php artisan testEverything is .env-driven; see .env.example for the full list. The
essentials:
| Variable | Purpose |
|---|---|
APP_PASSWORD_HASH |
Set via php artisan app:set-password "…" — the single login password |
APP_DISPLAY_TIMEZONE |
Timezone for display only (e.g. emails). Data is stored in UTC. Default Europe/Oslo |
DB_* |
SQLite locally, MySQL/MariaDB in production |
MAIL_* |
SMTP for sync reports & expiry warnings. MAIL_MAILER=log writes mail to the log instead of sending |
BANK_SYNC_REPORT_EMAIL |
Fallback recipient for sync/expiry emails (the in-app Settings value wins) |
GOCARDLESS_* / ENABLEBANKING_* |
Bank aggregator credentials — see docs/bank-setup.md |
You can run the app with no bank provider configured and enter everything manually; the bank integration is optional.
Finans talks to banks through a provider-agnostic interface, so you can use either or both of:
- GoCardless Bank Account Data (formerly Nordigen) — free tier, broad EEA coverage. Good if you already have an account.
- Enable Banking — free tier for personal, non-commercial use. Uses a self-signed RSA JWT per request.
Each connection records its own provider, so multiple providers can be active at once. Full step-by-step setup for both is in docs/bank-setup.md.
Once a provider is configured, go to the Bank page in the app, connect a bank, complete the consent flow, and link each bank account to a budget account.
The repo ships a Docker deployment — a multi-stage Dockerfile
(Node build → Composer build → php:8.4-fpm runtime with nginx + supervisor) and a
docker-compose.yml that runs three containers from the same
image:
| Container | Command | Role |
|---|---|---|
finans-web |
supervisord → nginx + php-fpm | serves the app on port 80 |
finans-worker |
queue:work --tries=3 --max-time=3600 --sleep=3 |
queue worker (the bank sync job) |
finans-scheduler |
schedule:work |
Laravel scheduler — replaces the cron entry |
This covers both background pieces needed for automatic sync and scheduled postings —
no Redis needed (the database queue driver is used), and no cron or Supervisor
on the host: schedule:work runs the scheduler in the foreground, and
restart: unless-stopped combined with --max-time=3600 lets Docker recycle the
worker every hour.
The scheduler runs the nightly bank sync (05:00), posts due scheduled transactions
(00:05), and checks for expiring bank consents (06:00) — see routes/console.php.
What you need to provide around the stack:
- A reverse proxy terminating TLS in front of
finans-web(Caddy, Traefik, nginx…). The app trusts forwarded headers viatrustProxies(at: '*')inbootstrap/app.php, so make sure your proxy setsX-Forwarded-Proto https— otherwise generated asset URLs come out ashttp://behind https. - A MySQL/MariaDB instance reachable from the containers. The bundled compose file
expects one in an external
app-backendnetwork rather than defining its own database service, so pointDB_HOSTat it. - The external networks referenced at the bottom of
docker-compose.yml. They're declaredexternal: trueand are site-specific — rename or replace them to match your own setup (e.g. drop the mail network if you send mail through an ordinary SMTP relay). - Persistence: only
./storage-appis bind-mounted (it holdsstorage/app, including the Enable Banking PEM key). Everything else in the container is disposable — the database lives in your MySQL/MariaDB instance.
deploy.sh is run on the server from the project root: git pull --ff-only
→ docker compose build → docker compose up -d → migrate --force → a check that the
caches were built. No maintenance mode is needed since the new image is built before the
swap, and neither composer install nor npm ci runs on the host — both happen in the
image build.
The config/route/view caches are built by docker/entrypoint.sh
on every container start, not by the deploy script. They live in the container's writable
layer, so anything written during a deploy is lost the next time a container is recreated
(docker compose up -d). Building them at startup means they always reflect the environment
the container was actually started with, and all three containers get them. It's best-effort:
if a cache command fails the container still starts, just without that cache.
Important production gotcha:
.envis in.dockerignoreand is never copied into the image. Config reaches the app purely as environment variables injected fromenv_file: .envwhen the container is created. So after editing.envyou must recreate the containers, not just restart them:docker compose up -d # add --force-recreate if compose says "up-to-date"
docker compose restartreuses the existing container and keeps serving the old values. All three containers rebuild their config cache at startup, so recreating them is all that is needed — no separatequeue:restartor manualconfig:cache.
Run artisan in production inside the container:
docker exec finans-web php artisan <command>.
Enable Banking requires public /privacy and /terms pages for app
approval. These are served as standalone Blade pages (outside the login/SPA) at
/privacy and /terms — make sure they're reachable on your domain.
- Serve over HTTPS only. The whole app is protected by one password and a long-lived session cookie.
- Never commit secrets.
.envis git-ignored; keep your bank credentials,APP_KEY, and Enable Banking private key out of version control. - The Enable Banking private key can be provided as PEM content in
.envor as a file path — a file path outside the web root is preferable in production. - Keep your host, PHP, and dependencies patched. You are handling read-only access to your own bank data; treat the server accordingly.
- A frontend change isn't showing up — run
npm run build(prod) ornpm run dev/composer dev(local). Vite assets must be rebuilt. Unable to locate file in Vite manifest— same fix: build the frontend.- Bank sync jobs never run — you don't have a queue worker running. Use
composer devlocally, or check that thefinans-workercontainer is up in production (docker compose ps). PSU_HEADER_NOT_PROVIDED/ASPSP_ERRORfrom Enable Banking — some banks require thepsu-ip-addressheader for unattended sync. SetENABLEBANKING_PSU_IPto your server's outbound public IP, then recreate the containers (docker compose up -d). Remember to update it if you migrate to a new server — the outbound IP changes with it. See docs/bank-setup.md.- Changed
.envin production but nothing happened — the containers still hold the environment they were created with. Recreate them withdocker compose up -d(see the gotcha above); a baredocker compose restartis not enough.
This is primarily a personal project, but issues and pull requests are welcome.
If you send a PR, please run php artisan test and vendor/bin/pint first.
MIT © 2026 Ragnar Holthe.
Built on Laravel (MIT). Not affiliated with YNAB, GoCardless, or Enable Banking.