Files
finance-duck/README.md
T
2026-09-10 12:30:42 +02:00

16 KiB

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:

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:

https://finance.example.internal/api/banking/callback

Replace finance.example.internal throughout these examples with your actual hostname. Register the exact URL with Enable Banking and configure the same value on the server, including scheme, hostname, port if applicable, and path.

For local use, the corresponding URL is:

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.

The Accounts screen displays a copyable callback URL. Before configuration it suggests the current browser origin; afterward it displays ENABLEBANKING_REDIRECT_URL.

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:

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.
  • Keep secrets/enablebanking.key private on your server and back it up securely.
  • 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 PEM RSA keys in PKCS#1 or PKCS#8 format, at least 2048 bits. 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:

  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.

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 and restart it

For a native process, set all three banking variables in the environment of the process that starts Finance Duck:

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.

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. Click Authorize bank.
  4. 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 90 days of booked transactions per account. Subsequent daily synchronization overlaps each account's last successful sync by 14 days. Sync now starts a manual synchronization; older history can be imported with CSV.

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.

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. Problems with the registered certificate/private key must be corrected in the server configuration and Enable Banking control panel.

Deployment options

Option Best fit Tradeoff / current support
Docker Compose Straightforward unattended deployment on a private server or VM Included Dockerfile and Compose configuration; packages the Go runtime dependencies and frontend together.
Native binary + systemd A small installation without a container daemon Binary build is supported; configure your own service user, persistent data directory, credentials, and systemd unit.
NixOS declarative OCI container Managing the application alongside other services in your NixOS configuration Reuse the Docker image through virtualisation.oci-containers; a ready-made NixOS module is not included.
Manual native run Development, evaluation, or troubleshooting The quick-start command works, but it is not an unattended service and stops when the process is terminated.

A VM or VPS can host either of the first two options; it does not require a separate application architecture. Kubernetes and multiple replicas add little here: run one application instance per canonical finance directory.

For an initial local deployment without bank credentials:

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:

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:

- ./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:

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:

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.
  • 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, deploy it with its runtime closure retained—copying a Nix-linked binary alone to another machine, or garbage-collecting its unrooted runtime paths, is not a reliable deployment method. The repository currently provides shell.nix, not a production Nix package or service module.

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:

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. This is an available deployment approach, not an included ready-to-enable Finance Duck NixOS module.

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.
  • Set ENABLEBANKING_REDIRECT_URL to that same origin plus /api/banking/callback, and register it exactly with Enable Banking.
  • 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 <VPN-IP>: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

Set OPENROUTER_API_KEY in the server environment, or add it to the Compose .env, then restart/recreate the service and choose a supported model in Settings.

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 the separately stored secrets. 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:

./bin/finance-duck -data ./finance -rebuild

See 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:

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.