# Finance Duck A self-hosted personal finance dashboard with a **Go backend**, **React frontend**, and **DuckDB analytics**. Human-readable `.finance` journals are the source of truth; DuckDB is a disposable index. Imported bank facts are separate from editable merchant, category, and tag classifications. Imports work without AI. Optional OpenRouter enrichment uses restrictive provider routing and omits amounts by default. > **There is no application login.** Keep Finance Duck behind your VPN. The default service and Docker Compose port bindings are loopback-only. Setting a hostname does not provide authentication or firewall protection. ## Quick start on NixOS From the repository root: ```sh nix-shell npm --prefix web ci npm --prefix web run build go build -o bin/finance-duck ./cmd/finance-duck ./bin/finance-duck -data ./finance ``` Open **http://localhost:8080**. For CSV imports, create a local account in **Accounts**, then import an N26 statement. For automatic bank imports, follow the setup below; authorization discovers the bank's accounts for you. Build React before building Go: its production assets are embedded in the binary. `shell.nix` supplies Go, Node.js, and the CGO toolchain needed by DuckDB. Node.js is not needed at runtime. ## Connect your bank Bank connections use Enable Banking. There are two separate credentials: - **Application ID + RSA private key:** authenticate Finance Duck to Enable Banking. - **Bank session:** grants access to the accounts you approve. Finance Duck obtains and stores this automatically after bank login. The callback's one-time authorization code is **not** a reusable API key or the application private key. ### 1. Choose the callback URL Use the address you open Finance Duck at through your VPN, followed by `/api/banking/callback`. For example: ```text https://finance.example.internal/api/banking/callback ``` Replace `finance.example.internal` throughout these examples with your actual hostname. **Settings → Enable Banking credentials** shows the exact callback URL to register, including scheme, hostname, port and path. On the deployed server it is `https://nixos.taile9e6d9.ts.net:8444/api/banking/callback`. For local use, the corresponding URL is: ```text http://localhost:8080/api/banking/callback ``` `localhost` works only when the browser completing bank login can reach Finance Duck on that computer. For a remote server, use its VPN-accessible hostname instead. **This is a browser redirect, not a webhook.** Your browser must be connected to the VPN and able to reach the callback. Finance Duck does not need to be publicly exposed. The server needs outbound HTTPS access to Enable Banking. **Settings** shows a copyable callback URL for the current browser origin and saves it with the application credentials. **Accounts** displays the configured callback URL, or suggests the current origin before configuration. ### 2. Generate an application key and certificate If you already have the private key for your registered Enable Banking application, use it instead of generating a replacement. Otherwise, on NixOS: ```sh umask 077 mkdir -p secrets chmod 700 secrets nix-shell -p openssl --run ' openssl genrsa -out secrets/enablebanking.key 4096 && openssl req -new -x509 -days 365 \ -key secrets/enablebanking.key \ -out secrets/enablebanking.crt \ -subj "/CN=Finance Duck" ' ``` - Upload **`secrets/enablebanking.crt`**, the public certificate, to Enable Banking. - Upload **`secrets/enablebanking.key`** through Finance Duck's Settings, and back up your original key securely. Never upload it to Enable Banking in place of the public certificate. - Do not rerun the generation command over a key already in use. A different private key will not authenticate against the existing registered certificate. The application accepts a single unencrypted PEM RSA key in PKCS#1 or PKCS#8 format, at least 2048 bits and no larger than 32 KiB. It creates signed application JWTs itself; you do not need to generate daily tokens. ### 3. Register and activate the Enable Banking application Open the [Enable Banking application control panel](https://enablebanking.com/cp/applications): 1. Register a **Production** application for real bank data, rather than a Sandbox application. 2. Upload your public certificate and register the callback URL. 3. Copy the resulting **application ID**. 4. Use **Activate by linking accounts** to link your own accounts. Enable Banking documents restricted production access for individual, non-commercial use. In that mode, only accounts linked to the application can be accessed. Follow its current registration requirements and terms: [whitelisting your own accounts](https://enablebanking.com/docs/api/linked-accounts/). **Linking accounts in their control panel activates your application; it does not create Finance Duck's bank session.** You must also authorize from Finance Duck in step 5, even for the same account. ### 4. Configure Finance Duck in Settings Open **Settings → Enable Banking credentials**: 1. Enter the application ID from Enable Banking. 2. Select the matching private-key `.pem` or `.key` file. 3. Register the displayed callback URL in Enable Banking if you have not already. 4. Click **Save configuration**, then proceed to **Accounts** to authorize your bank. Saving validates the local key and callback format and applies the provider immediately; it does not contact Enable Banking, register an application, or grant bank consent. **Configured** means locally configured, not externally verified. No SSH, environment file, Nix rebuild, or restart is required. For the same application ID, leave the upload empty to keep the saved key, or upload a replacement to rotate it. Existing bank sessions and sync cursors are preserved. Changing the application ID requires a new upload and invalidates old local bank connections, so reconnect afterward. **Remove configuration** disables banking and invalidates local connections after confirmation; accounts and transaction history stay intact. It does not revoke consent at your bank. Successful configuration changes also cancel pending authorization flows. The private key and application settings are stored atomically in `state/enablebanking.json` beneath the data directory, with permissions `0600`. The key is never returned by the API or saved in browser storage. Banking sessions are bound to that configuration's application generation so old sessions cannot be reused after switching applications or removal, even across restart. Protect backups of the entire data directory. #### Optional environment-managed setup For headless configuration, all three variables below remain a startup fallback **only when no saved banking configuration exists**. A UI-saved configuration, including explicit removal, overrides them. Environment changes require a restart; malformed saved credentials fail closed rather than reverting to the environment. ```sh export ENABLEBANKING_APP_ID="your-application-id" export ENABLEBANKING_KEY_FILE="$PWD/secrets/enablebanking.key" export ENABLEBANKING_REDIRECT_URL="https://finance.example.internal/api/banking/callback" ./bin/finance-duck \ -data ./finance \ -listen 127.0.0.1:8080 \ -public-url https://finance.example.internal ``` This example assumes a VPN-accessible reverse proxy forwards to `127.0.0.1:8080`. For local browser use without a proxy, set the redirect to `http://localhost:8080/api/banking/callback` and omit `-public-url`. Stop the old process before restarting; only one process may write a finance directory. Native runs do **not** automatically load `.env` files. Shell exports also do not configure an already-running process or a systemd service—configure that service's environment separately. For Docker, use the container-side key path and the configuration in [Docker Compose](#docker-compose). ### 5. Authorize from the UI Open **Accounts → Connect your bank**: 1. Enter the bank's exact Enable Banking institution name. 2. Set the country to **DE** for a German bank. 3. Set **History to import (months)**: **12** by default, or another whole number from **1 to 120**. 4. Click **Authorize bank**. 5. Log in on your bank's page and approve account access. Finance Duck verifies the callback state, exchanges the returned code for a `session_id`, stores the session locally with restrictive permissions, and wakes the synchronization worker immediately. You do not need to copy the code or session ID manually. Initial synchronization requests the selected number of **calendar months of booked transactions per account**, defaulting to **12 months**. The bank may provide less history. The choice is saved with the bank connection and reused on reconnection. Subsequent daily synchronization overlaps each account's last successful sync by **14 days**. **Sync now** starts a manual synchronization. Existing accounts keep their successful-sync cursors: changing the history choice or reconnecting does **not** backfill them. Older history can be imported with CSV. ### Reauthorize expired consent When consent expires or is revoked, the dashboard shows a warning such as **“ING needs reconnection.”** Open **Accounts** and click **Reconnect ING**. The bank and country are already selected. ```text Reconnect → bank login and approval → callback code → new session ↓ replace old consent and resume sync ``` Renewal preserves local account identities and existing transaction history. It does not create a second copy of the account or its transactions. Transient provider failures are displayed separately from expired consent. Reconnecting renews bank consent, not your application registration. Correct certificate registration in the Enable Banking control panel and update the matching private key in **Settings** (or your optional environment-managed setup). ## Deployment options | Option | Best fit | Included support | | ------------------------ | ------------------------------------ | -------------------------------------------------------------------------------------------- | | **Native NixOS service** | Deployment on your own NixOS machine | Pinned flake package and one systemd service, with persistent state and runtime credentials. | | **Docker Compose** | A private server using containers | Dockerfile and Compose configuration package the binary and frontend together. | | **Manual native run** | Development or troubleshooting | The quick-start command runs in your terminal. | **Run one application instance per canonical finance directory.** React is compiled and embedded in the Go binary: production needs neither Node.js nor a separate frontend service. ### Native NixOS service (recommended on NixOS) Add this input to your host flake after committing and pushing the deployment files: ```nix inputs.finance-duck.url = "git+https://git.larsnolden.com/lars/finance-duck.git"; ``` Include `finance-duck` in your flake's `outputs` arguments, then add these entries to your existing `nixosSystem.modules` list: ```nix finance-duck.nixosModules.default { services.finance-duck = { enable = true; publicURL = "http://localhost:8080"; # Optional, once you have installed the runtime credential file: # environmentFile = "/var/lib/finance-duck-secrets/environment"; }; } ``` Keep your existing host modules, including OpenClaw, unchanged. **Do not make this input's `nixpkgs` follow the host's NixOS 24.11 input:** Finance Duck needs Go 1.24 or newer. The package uses its own pinned toolchain; the service module works on the older host. Deploy and inspect: ```sh sudo nixos-rebuild switch --flake /etc/nixos#nixos systemctl status finance-duck journalctl -u finance-duck -f ``` The service runs as `finance-duck`, listens on **127.0.0.1:8080**, and stores all canonical data, settings, provider state, and the disposable DuckDB cache in **/var/lib/finance-duck**. It creates that directory with mode `0700`; rebuilds do not replace it. No firewall ports, VM, container, reverse proxy, or Tailscale configuration are created. For access from another machine, an SSH tunnel is sufficient: `ssh -L 8080:127.0.0.1:8080 your-server`, then open `http://localhost:8080`. For your existing Tailscale setup, instead set `publicURL` to the host's **actual full MagicDNS HTTPS origin**, for example `https://nixos.YOUR-TAILNET.ts.net:8444`, rebuild, and publish the loopback listener: ```sh sudo tailscale serve --bg --yes --https=8444 http://127.0.0.1:8080 ``` Choose an unused port; `8444` leaves the existing OpenClaw `8443` endpoint alone. Keep access restricted to trusted tailnet users. This application has no login; do not enable Funnel or expose it publicly. The Enable Banking callback must use that exact HTTPS origin plus `/api/banking/callback`. **Optional environment-managed credentials:** both Enable Banking and OpenRouter can be configured directly in **Settings**, without these steps. If you prefer a banking startup fallback, create a private runtime directory and install the key after the first rebuild creates the service account: ```sh sudo install -d -o root -g finance-duck -m 0750 /var/lib/finance-duck-secrets sudo install -o root -g finance-duck -m 0640 secrets/enablebanking.key \ /var/lib/finance-duck-secrets/enablebanking.key sudo touch /var/lib/finance-duck-secrets/environment sudo chmod 0600 /var/lib/finance-duck-secrets/environment sudoedit /var/lib/finance-duck-secrets/environment ``` Put the three `ENABLEBANKING_*` variables from [bank setup](#optional-environment-managed-setup) in that file together, using `ENABLEBANKING_KEY_FILE=/var/lib/finance-duck-secrets/enablebanking.key`. Enable `environmentFile` in your Nix configuration and rebuild. Later environment-file changes require `sudo systemctl restart finance-duck`. An optional `OPENROUTER_API_KEY` provides the equivalent AI startup fallback. For each provider, saved Settings configuration or explicit removal takes precedence over its environment fallback. Never place private keys or credential values in Nix configuration or the Nix store. **Existing local data:** this service does not automatically adopt `./finance`. Stop the previous instance, back it up, stop `finance-duck.service`, and copy the entire data directory into `/var/lib/finance-duck`, owned by `finance-duck:finance-duck`. Never run two writers against the same directory. **Updates:** update the host's Finance Duck input with `nix flake update finance-duck` in `/etc/nixos`, then run `nixos-rebuild switch` again. Normal NixOS rollback restores the binary, not financial data; keep separate data backups. To build without installing a service, run `nix build` in this repository (the new flake and `nix/` files must be tracked by Git). #### Deployed server The server at `bender@100.87.224.3` serves Finance Duck at **https://nixos.taile9e6d9.ts.net:8444**, accessible only through Tailscale. Port 443's existing Funnel and OpenClaw on port 8443 are separate and unchanged. ```sh ssh -t bender@100.87.224.3 'sudo finance-duck-update' # Deploy edits already in the server checkout without pulling: ssh -t bender@100.87.224.3 'sudo finance-duck-update --no-pull' ``` The command pulls `~/projects/finance-duck` with `git pull --ff-only`, snapshots its Git-tracked working files to `/var/lib/finance-duck-source`, updates only the host's `finance-duck` flake input, rebuilds NixOS, and checks backend health. New files must be tracked with Git to enter the snapshot. Ignored data, untracked secrets, and `.git` are excluded; never track credentials. The host's Nixpkgs and NixVirt pins are not updated. Host configuration lives in `/etc/nixos/flake.nix` and `/etc/nixos/modules/finance-duck.nix`; the latter defines the service settings, tailnet proxy, and updater. Application data lives separately in `/var/lib/finance-duck`, owned by `finance-duck` with mode `0700`. The initial deployment has no bank/AI credentials and does not import workstation data. Application and deployment source are tracked in the project repository. Commit and push changes before running the normal updater; `--no-pull` can deploy tracked local edits. Git refuses an update that would overwrite conflicting local edits. The updater never stashes, resets, commits, or pushes automatically. See `OPERATIONS.txt` for service inspection, backup, and rollback commands. ### Docker Compose For an initial local deployment without bank credentials: ```sh docker compose up --build -d docker compose logs -f finance ``` Open **http://localhost:8080**. The supplied configuration: - Publishes `127.0.0.1:8080`, not a public host interface. - Persists financial data in a named volume mounted at `/data`. - Runs as UID/GID `10001:10001` with a read-only root filesystem. - Drops Linux capabilities and enables `no-new-privileges`. - Restarts the service unless you explicitly stop it. To enable banking, create a private `.env` file in the repository root: ```dotenv FINANCE_PUBLIC_URL=https://finance.example.internal ENABLEBANKING_APP_ID=your-application-id ENABLEBANKING_KEY_FILE=/run/secrets/enablebanking.key ENABLEBANKING_REDIRECT_URL=https://finance.example.internal/api/banking/callback ``` Protect it with `chmod 600 .env`. `.env*` and `secrets/` are excluded from Git and the Docker build context. Compose reads `.env` for variable substitution; an already-exported shell variable takes precedence, so remove conflicting exports when switching from a native run. Uncomment the example key mount in `compose.yaml`, under `services.finance.volumes`: ```yaml - ./secrets/enablebanking.key:/run/secrets/enablebanking.key:ro ``` The private key must be readable by the container service user, while remaining inaccessible to unrelated users. A host-owned key with mode `0600` is not automatically readable by container UID `10001`. Set ownership or a restrictive ACL for the service's mapped host UID; rootless Docker and user-namespace remapping may use a different host UID. Do not solve this by making the private key world-readable. Then recreate the service with its new environment and mount: ```sh docker compose up --build -d ``` **Existing native data:** Compose's named volume does not automatically use `./finance`. If migrating an existing installation, stop the native service, back it up, and either copy the full data directory into the named volume or replace `finance-data:/data` with `./finance:/data`. A bind-mounted data directory must be writable by the container's mapped service UID. Do not point two running instances at it. Useful commands: ```sh docker compose logs -f finance docker compose stop # Start again or rebuild after updating the source: docker compose up --build -d ``` Do not use `docker compose down -v` unless you intend to delete the named data volume. ### Native binary + systemd Build the frontend and binary as in the quick start, then install the binary on a compatible Linux host. The React assets are embedded, and DuckDB is in-process: no separate frontend server or database service is needed. Configure a systemd service with: - A dedicated unprivileged user and a writable persistent data directory, such as `/var/lib/finance-duck`. - An absolute binary path and `-data /var/lib/finance-duck`. - `-listen 127.0.0.1:8080` behind your VPN-only reverse proxy. - `-public-url https://finance.example.internal` matching the browser origin. - Provider credentials configured through **Settings**, or optional banking environment variables and a private key readable by that service user. - Automatic startup and a restart policy, such as `Restart=on-failure`. The binary uses CGO/native DuckDB dependencies; it is not a universally portable static Go executable. Build for your target OS/architecture and provide the required native runtime libraries. On NixOS, use the native flake package and service module above to retain its runtime closure—copying a Nix-linked binary alone to another machine, or garbage-collecting its unrooted runtime paths, is not a reliable deployment method. ### NixOS declarative OCI container If you prefer `nixos-rebuild` to manage the service, use the included Docker image with `virtualisation.oci-containers` and Docker or Podman. Mirror the Compose configuration: one instance, loopback port mapping, persistent `/data`, a read-only private-key mount, runtime environment files, and the explicit `-public-url` command argument. Build the image from the repository: ```sh docker build -t finance-duck:local . ``` Make it available to the daemon used by the NixOS service, or publish it to a private registry and pin a version/digest. A rootless Docker image is not automatically available to the system Docker daemon or Podman. Keep private keys and API keys in protected runtime files or a secrets manager. **Do not embed secret values in Nix expressions or copy them into the world-readable Nix store.** The included NixOS module runs the native package; an OCI deployment requires your own container configuration. ### VPN and reverse-proxy requirements For any deployment: - Keep the application and proxy private to your VPN/firewall. A public DNS name or `-public-url` is not an access-control mechanism. - Point your chosen hostname at the VPN-accessible proxy and configure trusted HTTPS there. Finance Duck itself serves HTTP. - Forward to the application and preserve the public `Host` header. The application checks Host and mutation origins; forwarding only `X-Forwarded-Host` is insufficient. - For native runs, configure `-public-url`; for the supplied Compose configuration, set `FINANCE_PUBLIC_URL`. - Register the callback shown in **Settings → Enable Banking credentials**: the browser origin plus `/api/banking/callback`. For optional environment-managed setup, set `ENABLEBANKING_REDIRECT_URL` to that same URL. - Allow outbound HTTPS to Enable Banking, and optionally OpenRouter. No public inbound bank webhook is required. - Allow sufficiently long proxy requests for sequential bulk AI previews. A direct bind to a VPN interface is also supported with `-listen :8080` and a matching `-public-url`, provided the network is private and your registered callback uses that same browser address. HTTPS through your existing private reverse proxy is the preferred setup. ## Optional OpenRouter setup Open **Settings → OpenRouter credentials**, paste your API key, and click **Save key**. Then choose an exact OpenRouter `provider/model` identifier under **Classification preferences** and save those preferences. No SSH, Nix configuration changes, or service restart is needed. Use **Replace key** to rotate the credential or **Remove key** to disable AI. Changes apply to future classifications immediately and survive restart; an already-running classification keeps the key it started with. “Configured” means a key is present, not that OpenRouter has accepted it. A successful **AI classification → Analyse** request checks the key, model, and private routing together. The key is stored separately from preferences and journals in `state/openrouter.json` beneath your data directory (`/var/lib/finance-duck/state/openrouter.json` on the deployed server), as a private `0600` plaintext file. The API never returns it, and the UI clears the password field after saving rather than storing it in browser storage. Treat data-directory backups as containing credentials. Anyone able to access this no-login application can replace or remove the key: keep access restricted to trusted tailnet users. For administrator-managed startup configuration, `OPENROUTER_API_KEY` in the server environment or Compose `.env` remains an optional fallback **only while no saved credential file exists**. A UI-saved key takes precedence; **Remove key** persists an explicit disable, so a restart cannot silently restore the environment key. Environment-only changes require a restart/recreate. Bank imports do **not** require this key. Without AI, explicit merchant-default rules still work; unresolved transactions remain unclassified and editable. Every AI classification requests `provider.data_collection = "deny"`, `provider.zdr = true`, and `provider.require_parameters = true`. Unsupported private routing fails rather than falling back to a less restrictive provider. Amount sharing is off by default. Keep OpenRouter account prompt logging disabled as well. Automatic redaction minimizes data; it is not a guarantee that arbitrary transaction prose is anonymous. ## Data, backups, and recovery Back up the **entire canonical finance directory**, including registry files, journals, `config.toml` when present, and operational/recovery state, plus any separately stored environment-managed secrets. `state/openrouter.json` and `state/enablebanking.json` contain UI-managed credentials: protect backups accordingly, including the matching banking session state. Stop the service for a consistent filesystem backup. DuckDB under `cache/` can be excluded and rebuilt. Use **Settings → Rebuild index** while the app runs. For an offline rebuild, stop the service first and run: ```sh ./bin/finance-duck -data ./finance -rebuild ``` See [OPERATIONS.txt](OPERATIONS.txt) for the journal grammar, CSV formats and identity limitations, transfer matching, recovery, privacy boundaries, and reclassification behavior. ## Development checks After building the frontend: ```sh nix-shell --run 'go test ./... && go vet ./...' nix-shell --run 'npm --prefix web run build' ``` Tests cover deterministic financial invariants and mocked provider HTTP behavior. Real bank consent and real OpenRouter access require your own application registration and credentials.