Zenith is a personal finance dashboard for Swedish bank accounts. It connects to your banks through Enable Banking, keeps every account and transaction in your own PostgreSQL database, sorts transactions into categories (rules, your own corrections and an optional AI), and shows balances, credit accounts, virtual accounts, a monthly calendar, recurring payments with a monthly plan, and the value of your Avanza accounts. Accounts and transactions can be exported to CSV.
It runs entirely on your own computer and is never published on the internet. Devices on your home network use it at https://zenith.fin.se through nginx, and your own devices away from home reach the same address through Tailscale.
- How it fits together
- Prerequisites
- Database
- Enable Banking
- Backend configuration
- Run it for development
- Always-on deployment
- HTTPS on your network with nginx
- Away from home with Tailscale
- Avanza values (optional)
- systemd services
- Everyday tasks
- Troubleshooting
Phone / laptop at home ──────┐
Your devices, via Tailscale ─┴─https─▶ nginx :443 on the LAN address (zenith.fin.se)
│
▼
Website :4480 (Astro + Qwik, localhost only)
│
▼
API :4456 (.NET 8, localhost only) ──▶ PostgreSQL
│
├──▶ Enable Banking API (accounts, balances, transactions)
└──▶ AI provider (optional, OpenAI-compatible)
avanza-sync.timer (Mon + Thu) ──▶ avanza-sync.service ──▶ Avanza (optional, read only)
│
└──▶ PostgreSQL (encrypted login in, account values out)
| Folder | What it is |
|---|---|
core/zenith |
The API (C# / .NET 8, Dapper, Npgsql) |
www |
The website (Astro 5 + Qwik + Tailwind) |
tools |
Prisma schema and SQL migrations for the database |
deploy |
deploy.sh, which builds and runs Zenith as background services |
deploy/avanza-sync |
The optional Avanza fetcher: script, systemd service and timer, setup script and tests |
| Port | Used by |
|---|---|
| 4444 | Website in development (npm run dev) |
| 4445 / 4446 | API in development (Rider or dotnet run) |
| 4455 / 4456 | API of the always-on deployment |
| 4480 | Website of the always-on deployment (only reachable through nginx) |
| 80 / 443 | nginx (zenith.fin.se), on the LAN address only |
| 53 | AdGuard Home, if it answers DNS for zenith.fin.se |
Development and the always-on deployment use different ports, so both can run at the same time. They share the same database.
Only nginx listens on the network, and only on this computer's LAN address. The website and the API listen on localhost, and nothing is forwarded from your router. Away from home, Zenith can only be reached through Tailscale.
Tested on Linux Mint 22 (Ubuntu 24.04 base).
sudo apt install postgresql nginx mkcert libnss3-tools dotnet-sdk-8.0Node.js 24 through nvm:
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.3/install.sh | bash
nvm install 24You also need:
- an Enable Banking application (see step 3)
- optionally an AI provider with an OpenAI-compatible API (for example Groq or OpenAI) for sorting the transactions the rules leave in "Other"
- optionally AdGuard Home (or another local DNS server) so devices on your network can find
zenith.fin.se - optionally Tailscale to use Zenith away from home (step 8)
- for the optional Avanza values:
sudo apt install python3-venv, systemd 250 or newer (forsystemd-creds) and a synchronised clock (step 9)
Create the local certificate authority once. It is used for every HTTPS certificate below:
mkcert -installCreate a database user and a database:
sudo -u postgres createuser --pwprompt zenith
sudo -u postgres createdb --owner zenith zenithTell Prisma where it is in tools/.env (not committed):
DATABASE_URL="postgresql://zenith:<password>@localhost:5432/zenith"Install the tools and create all tables:
cd tools
pnpm install # or: npm install
npx prisma migrate deployEvery change to the schema in tools/prisma/schema/models comes with a migration in tools/prisma/migrations. Run npx prisma migrate deploy again whenever new migrations arrive. Use migrate deploy, not migrate dev: migrate dev can offer to wipe the database and also runs the ERD generator (puppeteer).
The Avanza fetcher gets its own database role, avanza-sync, which logs in over the unix socket without a password. Its setup script creates it (step 9).
- In the Enable Banking control panel, create an application and download its private key (
.pem). - Save the key as
core/zenith/EnableBanking/Keys/<application-id>.pem. Key files are ignored by git. - Add these redirect URLs to the application:
https://localhost:4444/api/callback/auth/aspsp(development)https://zenith.fin.se/api/callback/auth/aspsp(your network, step 7)
- Create
core/zenith/EnableBanking/workspace.json(ignored by git).keyPathis an absolute path, andAspspslists the banks you can connect:
{
"EnableBanking": {
"applicationId": "<application-id>",
"redirectUrl": "https://localhost:4444/api/callback/auth/aspsp",
"keyPath": "/home/<you>/Desktop/Zenith-Fin/core/zenith/EnableBanking/Keys/<application-id>.pem",
"Aspsps": [
{ "Nordea": { "country": "SE", "psuType": "personal" } },
{ "Handelsbanken": { "country": "SE", "psuType": "personal" } },
{ "Marginalen Bank": { "country": "SE", "psuType": "personal" } },
{ "Swedbank": { "country": "SE", "psuType": "personal" } }
]
}
}The always-on deployment uses the same file but replaces redirectUrl with https://zenith.fin.se/api/callback/auth/aspsp automatically.
core/zenith/appsettings.json is committed and holds the defaults: the Kestrel ports (4445/4446), the path to workspace.json and the AI defaults.
Create core/zenith/appsettings.Development.json (ignored by git) with your secrets:
{
"ConnectionStrings": {
"DefaultConnection": "Host=localhost;Port=5432;Database=zenith;Username=zenith;Password=<password>"
},
"AI": {
"Enabled": true,
"Model": "<model>",
"ApiKey": "<api-key>",
"BaseUrl": "https://api.groq.com/openai/v1",
"ReasoningEffort": "low"
}
}Set "Enabled": false to run without AI. Other settings you can add:
| Setting | Default | What it does |
|---|---|---|
Sync:BackgroundIntervalHours |
6 |
How often the API fetches new transactions on its own. Banks allow about four background fetches per account per day. 0 turns it off. |
AI:BatchSize |
40 |
Transactions per AI request |
AI:MaxBatchesPerRun |
5 |
AI requests per sync |
Avanza:PublicKeyFile |
/etc/avanza-sync/public.key |
The public key the API encrypts your Avanza login with. The Avanza setup script creates it (step 9). |
Avanza:PublicKey |
empty | The same key as base64 text, used instead of the file when set |
Things the API creates by itself in core/zenith (all ignored by git):
key-*.xml: the encryption keys for login sessions and saved personal numbers. Keep them. Deleting them logs everyone out and makes saved personal numbers unreadable.- the HTTPS development certificate for
https://localhost:4446. Create it once withdotnet dev-certs https.
API: open core/zenith in Rider and run the ZenithFin profile, or:
cd core/zenith
dotnet dev-certs https
ASPNETCORE_ENVIRONMENT=Development dotnet runThe API listens on https://localhost:4446 (Swagger at /swagger in development).
Website: the development server needs a localhost certificate in www:
cd www
npm install
mkcert localhost 127.0.0.1 ::1
npm run devmkcert creates localhost+2.pem and localhost+2-key.pem, which astro.config.ts reads. Open https://localhost:4444, create an account and connect your banks under Accounts → Add a bank.
Useful website commands:
| Command | What it does |
|---|---|
npm run dev |
Development server on https://localhost:4444 |
npx astro check |
Type check |
npm run build |
Lint with fixes, format with Prettier, then build |
The website reads two optional environment variables: ZENITH_API_URL (default https://localhost:4446/api/v1) and, at build time, ZENITH_DOMAIN (default zenith.fin.se), the address Astro accepts form posts from.
deploy/deploy.sh turns this computer into the Zenith server. It runs as your own user (no sudo) and:
- publishes the API to
~/.local/share/zenith-fin/apiand copiesappsettings.Development.jsonthere asappsettings.Production.json(readable only by you) - builds the website to
~/.local/share/zenith-fin/web - creates a localhost certificate in
~/.config/zenith-fin/tlsfor the internal HTTPS between nginx, the website and the API - writes two systemd user services:
zenith-api: Production mode on localhost 4455/4456. Its working folder iscore/zenith, so it sharesworkspace.json, the bank key and the encryption keys with development.zenith-web: the website on127.0.0.1:4480
- writes the nginx site to
~/.config/zenith-fin/nginx/zenith.conf, listening only on this computer's LAN address, and tells you when it needs installing - enables both services and "linger", so Zenith starts at boot even before anyone logs in
./deploy/deploy.shRun it again after every update. The previous build is kept as api.previous / web.previous, and the website's build log is in ~/.local/share/zenith-fin/build.log. You can override ZENITH_DOMAIN, ZENITH_WEB_PORT, ZENITH_API_HTTP_PORT, ZENITH_API_HTTPS_PORT and ZENITH_ADDRESS (the LAN address nginx listens on; by default the address ip -4 route get 1.1.1.1 reports).
How to check, restart and read the logs of the services is in step 10.
This makes Zenith reachable as https://zenith.fin.se from every device on your home network. The name only exists on your network, so the certificate comes from your own mkcert authority.
mkdir -p ~/.config/zenith-fin/tls
mkcert -cert-file ~/.config/zenith-fin/tls/zenith.fin.se.pem -key-file ~/.config/zenith-fin/tls/zenith.fin.se-key.pem zenith.fin.se
sudo install -d -m 755 /etc/nginx/ssl
sudo install -m 644 ~/.config/zenith-fin/tls/zenith.fin.se.pem /etc/nginx/ssl/zenith.fin.se.pem
sudo install -m 600 ~/.config/zenith-fin/tls/zenith.fin.se-key.pem /etc/nginx/ssl/zenith.fin.se-key.pem
sudo install -m 644 "$(mkcert -CAROOT)/rootCA.pem" /etc/nginx/ssl/zenith-ca.crtzenith-ca.crt is the public half of your certificate authority. nginx offers it at http://zenith.fin.se/zenith-ca.crt so other devices can install it. The private half, rootCA-key.pem in mkcert -CAROOT, must never leave this computer. mkcert certificates expire after about 27 months. Renew by running this step again.
Run ./deploy/deploy.sh first. It generates the site config, then install it:
sudo install -m 644 ~/.config/zenith-fin/nginx/zenith.conf /etc/nginx/sites-available/zenith
sudo ln -sf /etc/nginx/sites-available/zenith /etc/nginx/sites-enabled/zenith
sudo nginx -t && sudo systemctl reload nginxThe site listens only on the LAN address, redirects http:// to https://, serves the certificate authority file, forwards everything else to the website, and refuses requests for any other host name.
sudo ufw allow from 192.168.1.0/24 to any port 80,443 proto tcpIf AdGuard Home on this computer answers DNS for your network (7.4), open its port for the same range:
sudo ufw allow from 192.168.1.0/24 to any port 53Use your own network range if it isn't 192.168.1.0/24. Never forward ports 80 or 443 on your router: Zenith is not meant to be reachable from the internet.
Point the name at this computer's LAN address (ip -4 route get 1.1.1.1 shows it after src, for example 192.168.1.178).
With AdGuard Home: under Filters → DNS rewrites, add zenith.fin.se → <lan-ip>. Then devices have to use AdGuard as their DNS server:
- If your router lets you set the DHCP DNS server: set it to
<lan-ip>. Every device picks it up automatically. - If it doesn't (like many ISP routers), set it per device:
- Computers (Linux, Mac, Windows): add
<lan-ip> zenith.fin.seto the hosts file (/etc/hosts, orC:\Windows\System32\drivers\etc\hostson Windows). This computer needs the line too. - iPhone / iPad: Settings → Wi-Fi → ⓘ → Configure DNS → Manual → only
<lan-ip>. - Android: Wi-Fi network → Edit → Advanced → IP settings: Static, with a free IP address, gateway
192.168.1.1, prefix length24and DNS 1<lan-ip>.
- Computers (Linux, Mac, Windows): add
Phones that use AdGuard look up every website through this computer while on your Wi-Fi, so keep it running.
Open http://zenith.fin.se/zenith-ca.crt (plain http) on the device and install it:
- iPhone / iPad: allow the download, then Settings → Profile Downloaded → Install. Then Settings → General → About → Certificate Trust Settings → turn it on.
- Android: Settings → Security → Encryption & credentials → Install a certificate → CA certificate.
- Windows: open the file → Install Certificate → Local Machine → Trusted Root Certification Authorities.
- Mac: open the file in Keychain Access → Trust → Always Trust.
https://zenith.fin.se now opens with a padlock.
Make sure https://zenith.fin.se/api/callback/auth/aspsp is one of the application's redirect URLs (step 3). Without it, connecting a bank from the deployed site fails.
Zenith is never opened to the internet. To use it away from home, put this computer and your own devices in the same Tailscale network (tailnet). Tailscale carries the traffic to this computer's LAN address, so https://zenith.fin.se, the certificate and the Enable Banking redirect work exactly as at home.
curl -fsSL https://tailscale.com/install.sh | sh
sudo tailscale up
sudo tailscale set --advertise-routes=192.168.1.178/32Use your own LAN address. The /32 route shares this computer only, not the rest of your network. Then, in the admin console:
- Machines → this computer → ⋯ → Edit route settings → approve
192.168.1.178/32. - ⋯ → Disable key expiry, so the server doesn't drop out of the tailnet when its key expires.
In the admin console under DNS → Nameservers → Add nameserver → Custom, enter this computer's LAN address (192.168.1.178, where AdGuard Home answers, 7.4) and turn on Restrict to domain with zenith.fin.se. Your devices then ask AdGuard for zenith.fin.se through Tailscale and keep their usual DNS for everything else.
tailscale dns status on this computer lists the route as zenith.fin.se -> 192.168.1.178 under Split DNS Routes.
- Install Tailscale and sign in to the same tailnet. Keep Use Tailscale DNS on (the default).
- Phones, tablets, Windows and Mac use the approved route automatically. On Linux run
sudo tailscale set --accept-routes. - Install the certificate authority as in 7.5 if the device hasn't already.
With Tailscale connected, open https://zenith.fin.se from anywhere.
- Don't use Tailscale Funnel or
tailscale servefor Zenith. Funnel publishes a service on the internet.tailscale funnel statusshould sayNo serve config. - Tailscale's access controls decide which devices in your tailnet may use the route. The default policy lets all of them in, so tighten it if you share your tailnet with others.
The Avanza page shows the total value of each of your Avanza accounts and how it changes over time, and the accounts count in the total on Accounts and in its CSV export. Avanza has no official API, so a separate fetcher uses an unofficial one. That breaks Avanza's terms of use, so the fetcher only reads the account overview and logs in at most twice a week.
Set it up after the first deployment:
./deploy/deploy.sh
sudo ./deploy/avanza-sync/setup.shThe script creates the avanza-sync system user, installs the fetcher in /opt/avanza-sync, creates the key pair (the public key in /etc/avanza-sync/public.key, the private key encrypted with systemd-creds), creates the database role and installs and starts the timer. If it says the service user could not connect to the database, add this line above the other local lines in pg_hba.conf and reload PostgreSQL:
local zenith avanza-sync peer
Then open Avanza in Zenith and enter your username, password and TOTP secret. The API only holds the public key, so it can encrypt your details but never read them back. Only the fetcher can decrypt them.
How the keys are kept, the schedule and its limits, the statuses, key rotation, the tests and how to remove it are described in deploy/avanza-sync/README.md.
| Unit | Kind | Unit file | What it does |
|---|---|---|---|
zenith-api.service |
user | ~/.config/systemd/user/, written by deploy.sh |
Runs ~/.local/share/zenith-fin/api/ZenithFin.dll in Production on localhost 4455/4456 from core/zenith |
zenith-web.service |
user | ~/.config/systemd/user/, written by deploy.sh |
Runs the website (~/.local/share/zenith-fin/web/dist/server/entry.mjs) on 127.0.0.1:4480 after the API |
avanza-sync.service |
system | /etc/systemd/system/, copied from deploy/avanza-sync by setup.sh |
Fetches Avanza values once as the avanza-sync user in a strict sandbox, then exits |
avanza-sync.timer |
system | /etc/systemd/system/, copied from deploy/avanza-sync by setup.sh |
Starts the fetch on Monday and Thursday at 07:00 with up to 90 minutes of random delay |
The user services restart by themselves if they crash, and they start at boot because deploy.sh turns on linger for your user (loginctl enable-linger). deploy.sh rewrites their unit files on every run, so change the script, not the files. To change the Avanza units, edit them in deploy/avanza-sync and run setup.sh again.
systemctl --user status zenith-api zenith-web
journalctl --user -u zenith-api -u zenith-web -f
systemctl --user restart zenith-api zenith-web
systemctl --user stop zenith-web zenith-api
systemctl --user cat zenith-api
systemctl list-timers avanza-sync.timer
journalctl -u avanza-sync.service -n 20 --no-pager
sudo systemctl start avanza-sync.service
systemd-analyze security avanza-sync.serviceZenith also relies on these system services: postgresql, nginx, tailscaled (step 8), AdGuard Home if it answers your DNS, and systemd-timesyncd or chrony, because the Avanza login codes need the right time.
Update Zenith
git pull
cd tools && npx prisma migrate deploy && cd ..
./deploy/deploy.shIf the update changed deploy/avanza-sync, also run sudo ./deploy/avanza-sync/setup.sh. It keeps the existing key, so your saved Avanza details stay valid.
Connect or reconnect a bank: Accounts → Connected banks → Reconnect, or Add a bank. Tick every account you want Zenith to see. An account the bank no longer shares is marked "no longer shares this account".
Export to CSV: Accounts → Export CSV gives one row per account with balances and monthly figures. Calendar → Export CSV gives the transactions of any days, months or years, up to 10 years at a time. Choose For Excel (semicolons and decimal commas, for Swedish Excel) or Standard CSV (commas and decimal points, for LibreOffice, Google Sheets, Python and R).
Plan your months: Recurring → Monthly plan shows what comes in, goes out and is left for this month and the next 11. Add income and fixed costs as your own rows (every few months or years from the date they last happened, with an optional end), or add payments from your history, which then follow your bank's amount and dates until you change them.
Start over with an empty database (deletes all users, banks, transactions and Avanza values):
systemctl --user stop zenith-web zenith-api
cd tools && npx prisma migrate reset && cd ..
systemctl --user start zenith-api zenith-webRemove your banks in Zenith first if you also want to end Zenith's access at the banks. If you use Avanza, run sudo ./deploy/avanza-sync/setup.sh afterwards to give the fetcher its database access back.
| Problem | What to check |
|---|---|
502 Bad Gateway right after a deploy |
The website takes a second to start. Reload. |
| Page never loads on a phone | DNS (7.4): does http://zenith.fin.se reach this computer? Then check the firewall (7.3). |
| Works at home but not away | Is Tailscale connected on the device? Is the route approved (8.1), and accepted on Linux (8.3)? Does zenith.fin.se resolve through the split DNS (8.2)? If the connection times out, let Tailscale traffic through ufw: sudo ufw allow in on tailscale0 to any port 53 and sudo ufw allow in on tailscale0 to any port 80,443 proto tcp. |
| Certificate warning | The device hasn't installed zenith-ca.crt yet (7.5). |
| Connecting a bank fails after the bank login | The redirect URL for the address you use isn't registered at Enable Banking (7.6). |
| A credit or virtual account stays at 0 | The linked account isn't shared with Zenith anymore. Reconnect the bank and tick the account. |
| "Avanza is not set up on this server yet" | Run sudo ./deploy/avanza-sync/setup.sh (step 9). It creates /etc/avanza-sync/public.key, which the API reads. |
| Avanza says "Login refused" or "Enter your details again" | Avanza refused the login (AUTH_FAILED), or the key was replaced (KEY_MISMATCH). Enter your details again on the Avanza page. The fetcher doesn't try again until you do. |
| Avanza says "Last fetch failed" | The run hit an error, shown by its class (for example ConnectionError). It tries again at the next scheduled time. See journalctl -u avanza-sync.service. |
| An exported CSV opens in one column or with broken numbers | Export again as Standard CSV, or tell the import that the separator is a semicolon and the decimal sign a comma. |
| Logs | journalctl --user -u zenith-api -u zenith-web -f, journalctl -u avanza-sync.service, /var/log/nginx/error.log |