picsou-finance
Self-hosted personal finance dashboard
Track bank accounts, brokerage, crypto, and net worth — all in one place.
Getting started · Features · Another machine · Development · Security
Disclaimer
Picsou is designed for personal, local use.
It stores sensitive financial data (balances, transactions, bank session tokens). It supports multi-member families, optional TOTP 2FA, and audit logging of setup/admin actions, but it has not undergone a professional security audit.
Do not expose it on the public internet. Use it on your local machine or home network behind a firewall. If you choose to expose it, you do so at your own risk.
Features
- Account aggregation — Bank accounts (LEP, PEA, Livret, current), brokerage, crypto wallets, on-chain addresses, debts/loans
- Bank sync — Enable Banking (PSD2/OAuth, 2000+ EU banks) and SimpleFIN (setup token, mostly US banks via SimpleFIN Bridge).
- BoursoBank — Current accounts, livrets and the PEA/CTO with its cash and positions, via a local read-only sidecar. Reaches the securities account PSD2 cannot.
- Brokerage sync — Trade Republic via WebSocket or CSV import, and Bourse Direct PEA/CTO positions via a local read-only sidecar
- Employee savings — Amundi Épargne Salariale plans (PEE/PEG, PERCO, PER) and their FCPE lines, via a local read-only sidecar
- Crypto — Binance and Meria exchange sync, on-chain BTC/ETH/SOL address tracking
- Live prices — CoinGecko (crypto), Yahoo Finance (stocks/ETFs)
- Real estate — Automatic monthly valuation of French property from open data (DGFiP transactions via Cerema, IGN geocoding, INSEE price index). No API key, no subscription. Ownership shares between family members, mortgages linked to the property they finance, and gross/net property equity.
- Security insight — Per-holding asset-type detection and ETF composition (top holdings, country & sector breakdowns) in the holding detail modal
- Net worth tracking — Historical snapshots, stacked area charts, per-account breakdown
- Savings goals — Targets with deadlines, progress tracking across accounts
- Multi-member family — One admin manages multiple profiles (children, spouse). Per-resource sharing (
NONE/ALL/MANUAL), optional activation links to upgrade a managed profile to a full login. - 2FA + Remember Me — Opt-in TOTP per user, 10 single-use recovery codes, 90-day "Remember Me" cookie with rotating tokens, "Trust this device" to skip TOTP, per-session revocation from settings.
- GDPR data export — Self-service ZIP export (JSON + per-entity CSV) gated by re-authentication, rate-limited to 5/hour.
- Finary import — XLSX upload or direct API sync (Clerk + TOTP). API sync imports cash, transactions, and holdings (securities, crypto, fonds euros) using Finary’s EUR
display_*values. The mapping wizard matches existing Picsou accounts (external id, then unique name + type) instead of offering to create duplicates. - i18n — English and French
- Dark mode — System/light/dark with flash-free theme switching
Architecture
┌──────────────────┐ ┌───────────────────────┐ ┌────────────┐
│ React Frontend │────▶│ Spring Boot Backend │────▶│ PostgreSQL │
│ (Vite/Bun) │◀────│ (Tomcat :8080) │ │ (:5432) │
└──────────────────┘ └───────────┬────────────┘ └────────────┘
│
┌──────────────┼──────────────┬──────────────┐
▼ ▼ ▼ ▼
Enable Banking CoinGecko Yahoo Finance Trade Republic
(PSD2/OAuth) (crypto) (stocks/ETF) (WebSocket)
Modules, data flows and key constraints: docs/ARCHITECTURE.md.
Tech stack
| Layer | Technology |
|---|---|
| Backend | Java 21, Spring Boot 3.5, Maven |
| Frontend | React 19, TypeScript 5.9, Vite 7, Tailwind v4, Bun |
| Database | PostgreSQL 16, Flyway |
| Runtime | Docker (one non-root Spring Boot process) |
Getting started
Prerequisites
- Docker & Docker Compose v2.17+ (v2.33+ to build the sidecars from source with
--build) - (Optional) An Enable Banking account for bank sync
1. Clone
git clone https://github.com/Cloeille/picsou-finance.git
cd picsou-finance
2. Run (zero-config)
Picsou publishes pre-built, multi-arch (amd64/arm64) images to the GitHub Container Registry, so there is nothing to compile:
| Image | Package |
|---|---|
ghcr.io/cloeille/picsou-finance |
picsou-finance — app (frontend + backend) |
ghcr.io/cloeille/picsou-finance/tr-auth |
picsou-finance/tr-auth — Trade Republic auth sidecar |
ghcr.io/cloeille/picsou-finance/bourse-direct-auth |
Bourse Direct login/2FA sidecar |
ghcr.io/cloeille/picsou-finance/amundi-auth |
Amundi Épargne Salariale login/2FA sidecar |
ghcr.io/cloeille/picsou-finance/bourso-auth |
BoursoBank login/2FA sidecar |
ghcr.io/cloeille/picsou-finance/caisse-epargne-auth |
Caisse d'Epargne read-only sidecar (no in-app login yet) |
ghcr.io/cloeille/picsou-finance/fortuneo-auth |
Fortuneo login/2FA and portfolio sidecar |
ghcr.io/cloeille/picsou-finance/cmes-auth |
Crédit Mutuel / CIC Épargne Salariale login/2FA and plans sidecar |
Bank connectors (the *-auth sidecars) are opt-in: with no profile, Compose pulls and runs only the app and the database. Pick the banks you sync through a comma-separated COMPOSE_PROFILES list, or use all-connectors for every one. Add tls to that same list when you use the bundled Caddy proxy. Connector profiles: amex, amundi, bourse-direct, bourso, caisse-epargne, cic, cmes, corum, degiro, fortuneo, revolut, sofidy, and tr (Trade Republic). The tls profile enables the bundled Caddy proxy. The app and the enabled connectors authenticate each other with one shared secret, APP_SIDECAR_API_KEY, required as soon as one connector is enabled; each connector refuses to start without it. Set the shared key before the first launch:
[ -f docker/.env ] || cp docker/.env.example docker/.env
grep -q '^COMPOSE_PROFILES=' docker/.env || printf '\nCOMPOSE_PROFILES=%s\n' bourso,revolut >> docker/.env
grep -q '^APP_SIDECAR_API_KEY=' docker/.env || printf '\nAPP_SIDECAR_API_KEY=%s\n' "$(openssl rand -base64 32)" >> docker/.env
Edit docker/.env and set COMPOSE_PROFILES=bourso,revolut. If the file already has a
COMPOSE_PROFILES entry, edit that line instead of adding another one. Include tls in the
same list when you use the Caddy proxy, for example COMPOSE_PROFILES=bourso,revolut,tls.
Compose reads COMPOSE_PROFILES from docker/.env, so every later pull, up and down covers the same connectors. Enable another bank by adding it to the list and running pull then up -d. To disable a bank, remove it from the list and stop and remove its container with docker compose -f docker/docker-compose.yml rm --stop --force <bank>-auth. up -d and down leave a connector outside the active profiles running, and the app keeps syncing through it. Repeat the stop step whenever you disable a bank. You do not need it when the list contains all-connectors. Syncing a bank whose connector is not running shows that bank's "unavailable" error.
Upgrading an install that ran every connector? Set
COMPOSE_PROFILES=all-connectors(or list your banks) indocker/.envfirst. If you list only some banks, stop excluded connectors withdocker compose -f docker/docker-compose.yml rm --stop --force <bank>-auth. Edit an existingCOMPOSE_PROFILESline instead of adding a duplicate. See the CHANGELOG.
docker compose -f docker/docker-compose.yml pull # fetch the published images from GHCR
docker compose -f docker/docker-compose.yml up -d
The compose file pins
:latest. To follow the bleeding edge instead, override with:nightly(built on everymainpush), or pin a release such as:1.0.0.Building from source instead of pulling? Run
docker compose -f docker/docker-compose.yml up --build— thebuild:sections are kept for contributors.
On first launch the app auto-generates JWT_SECRET and CRYPTO_ENCRYPTION_KEY (persisted to the picsou_data volume under /data/.secrets/). The database password defaults to picsou for both the app and PostgreSQL; set POSTGRES_PASSWORD in docker/.env before the first start to change it.
[!IMPORTANT] Planning to sync bank accounts? Set up HTTPS now, before opening the wizard — jump to step 3. Enable Banking refuses plain-HTTP callback URLs, and the wizard permanently stores values derived from the address you open it on. Run it over HTTP and you will have to correct three settings by hand afterwards; run it over HTTPS and they are all derived correctly with nothing to type.
Otherwise, open http://localhost:8080 — the setup wizard walks you through admin credentials, CORS, and (optionally) Enable Banking.
For subsequent production releases, do not replace the app with a bare compose up.
Before upgrading, check that the new tls-proxy subnet 172.30.253.0/29 does not overlap
an existing Docker, LAN, or VPN network. This applies to non-TLS installs too, because
app always joins this bridge. If Docker reports networks have overlapping IPv4,
docker compose up -d and deploy.sh cannot proceed. If it conflicts, set PICSOU_PROXY_SUBNET,
PICSOU_PROXY_IP, and PICSOU_PROXY_IP_RANGE together in your existing docker/.env
to an unused subnet, a proxy address, and an automatic allocation range within that subnet.
Keep the proxy address and gateway outside the allocation range. See the
proxy network requirements.
Use the migration-safe deployment workflow
to rehearse migrations on a restored backup, migrate the live database, and verify the API
before declaring the release ready. Keep Flyway enabled; a historical
SPRING_FLYWAY_ENABLED=false workaround is not a migration strategy.
3. HTTPS (decide before the first launch)
Required for bank sync. Enable Banking rejects plain-HTTP callback URLs for PRODUCTION applications, and PRODUCTION is the only mode that lists real banks — so an HTTP-only install can never complete a bank connection. Everything else in Picsou works fine over HTTP.
The stack ships an optional Caddy TLS terminator, off by default so it cannot collide with an ingress proxy you already run.
3a. Choose a hostname
Caddy picks the certificate strategy from PICSOU_DOMAIN alone — there is no issuer to configure:
PICSOU_DOMAIN |
Certificate | What you must do |
|---|---|---|
A real domain (picsou.example.com) resolving to this host, :80+:443 reachable |
Let's Encrypt, publicly trusted | Nothing. Issued and renewed automatically |
A LAN IP (192.168.1.50) or a .local / .internal name |
Caddy's built-in CA | Install its root once per device (3c) |
picsou.localhost (the default) |
Caddy's built-in CA | Resolves only on the Docker host — fine for a smoke test, useless from a phone |
A real domain is worth the effort: it is the only option with no per-device step. It does not need to be publicly reachable for day-to-day use — only during certificate issuance.
3b. Start the stack with TLS
This replaces the up -d from step 2. Edit the existing COMPOSE_PROFILES line in
docker/.env to include tls and the connector profiles you need before starting the stack.
# Start from the commented reference file if you don't have a .env yet
[ -f docker/.env ] || cp docker/.env.example docker/.env
# Edit or uncomment the existing COMPOSE_PROFILES line in docker/.env to include tls and the
# connector profiles you need. Do not add a second COMPOSE_PROFILES line.
# printf's leading \n guards against a .env that lacks a trailing newline —
# plain `echo >>` would concatenate onto the last line and corrupt both values.
printf '\nPICSOU_DOMAIN=picsou.example.com\n' >> docker/.env
docker compose -f docker/docker-compose.yml pull
docker compose -f docker/docker-compose.yml up -d
[!IMPORTANT] The
pullis not optional on an existing install. Compose will not re-fetch:latestif the image already exists locally, so you would keep an older build that sends HSTS unconditionally — which, combined with the internal-CA certificate from 3c, is precisely the lockout this setup exists to avoid. Building from source? Useup -d --buildinstead.
Note that plain HTTP stays published on :8080 at this point. That is deliberate — it is your way
back in if the certificate is not trusted yet. Close it in 3d, once HTTPS is confirmed working.
Verify before going further:
curl -sk -o /dev/null -w 'https=%{http_code}\n' https://picsou.example.com/
curl -s -o /dev/null -w 'http=%{http_code} -> %{redirect_url}\n' http://picsou.example.com/
Expect https=200 and an http=308 redirect. Now open https://picsou.example.com and run the
setup wizard. It reads your browser's origin, so the callback URL, allowed origins, and secure-cookie
flag all land on the HTTPS values with nothing to type.
Finally, register https://picsou.example.com/sync/callback in the Enable Banking portal — it must
match byte-for-byte, or auth initiation fails with REDIRECT_URI_NOT_ALLOWED.
3c. No domain? Trust the internal CA
Certificate trust cannot be established remotely — each device must be told once that your CA is legitimate. Export the root:
docker compose -f docker/docker-compose.yml cp \
proxy:/data/caddy/pki/authorities/local/root.crt ./picsou-root-ca.crt
Install it per device: Keychain Access (macOS), Settings → Security → Encryption & credentials
(Android), update-ca-certificates (Linux), certmgr.msc (Windows). Firefox keeps its own store —
Settings → Privacy & Security → Certificates.
Only your devices need this. Enable Banking never fetches the callback URL; the redirect happens entirely inside the user's browser, so an internal CA is perfectly sufficient for bank sync.
3d. Hardening, once HTTPS works
Two steps, once HTTPS works from every device you use.
Close the plain-HTTP port. Until now :8080 has served the whole app in cleartext to the LAN,
credentials included. Add the overlay file to remove that publish — the container still listens on
8080 inside the Docker network, which is how Caddy reaches it:
docker compose -f docker/docker-compose.yml \
-f docker/docker-compose.no-http.yml \
up -d
Keep passing both -f flags from then on, or the publish comes back. (Requires Compose v2.24+ for
!reset; on older versions comment out the ports: block in docker/docker-compose.yml instead.)
Enable HSTS — only with a publicly-trusted (Let's Encrypt) certificate:
printf '\nHSTS_ENABLED=true\n' >> docker/.env
docker compose -f docker/docker-compose.yml \
-f docker/docker-compose.no-http.yml \
up -d --force-recreate app
Both -f flags again — dropping the overlay here would re-publish the :8080 port you just closed,
and --force-recreate would make it happen immediately.
HSTS_ENABLED sends Strict-Transport-Security, telling browsers to refuse plain HTTP for this
host for a year. It is off by default, so if you run a public HTTPS deployment you should turn it
on — otherwise nothing stops a browser being downgraded to the :8080 port if it is exposed.
--force-recreate app is needed because this value reaches the container only through env_file,
which does not always change the rendered service config — a plain up -d can report the container
as up-to-date and leave the old setting in place. Verify it took effect:
curl -sI https://picsou.example.com/ | grep -i strict-transport
Exactly one Strict-Transport-Security line when enabled, none when disabled.
In this image Spring Boot is the only emitter, gated by HSTS_ENABLED, and it sends the header
once on every response over HTTPS. To check the backend without the proxy in front, ask it
directly from inside the container (curl is in the base image). The backend applies
X-Forwarded-Proto from any peer, so the loopback request below counts as HTTPS; it prints one
line when HSTS_ENABLED is on and nothing when it is off:
docker compose -f docker/docker-compose.yml exec app \
curl -sI -H 'X-Forwarded-Proto: https' http://127.0.0.1:8080/actuator/health \
| grep -i strict-transport
[!WARNING] Never set
HSTS_ENABLED=truewith an internal-CA or mkcert certificate. The browser remembers the policy and then refuses to offer the "proceed anyway" bypass for a certificate it does not trust, locking you out with no in-app recovery — only clearing HSTS state in browser internals (chrome://net-internals/#hsts) gets you back. It is off by default for this reason.
3e. Already running your own reverse proxy?
Skip the profile entirely and point Traefik / Nginx Proxy Manager / a Cloudflare Tunnel at the app
container's :8080. Your proxy must forward X-Forwarded-Proto: https (all of the above do by
default). Set PICSOU_PROXY_IP in docker/.env to the proxy's exact IPv4 address as seen by
the app, then recreate the app. The app accepts forwarded client addresses only from that
peer; direct connections retain their connection address. The bundled tls profile configures
its dedicated proxy address automatically. See proxy configuration
for subnet overrides.
Picsou uses the forwarded public scheme, host and port for same-origin HTTPS requests. For a
same-origin deployment leave ALLOWED_ORIGINS blank and let the wizard handle it, and keep
SECURE_COOKIES=true.
3f. Adding HTTPS to an install that is already set up
If the wizard already ran over HTTP, three values are stored in the database, which takes
precedence over .env — editing environment variables will not change them.
Add tls to the existing COMPOSE_PROFILES list in docker/.env, keeping any connector profiles
already there. Start TLS as in 3b. Do not add the no-http overlay from 3d yet. Keep plain HTTP
on :8080 as a fallback until HTTPS works from the machines you use:
[ -f docker/.env ] || cp docker/.env.example docker/.env
printf '\nPICSOU_DOMAIN=picsou.example.com\n' >> docker/.env
docker compose -f docker/docker-compose.yml pull
docker compose -f docker/docker-compose.yml up -d
Then log in over HTTPS and fix the three settings in the UI:
| Setting | Where | New value |
|---|---|---|
| Redirect URI | Admin → Integrations → Enable Banking | https://<domain>/sync/callback |
| Allowed origins | Admin → Security | add https://<domain> |
| Secure cookies | Admin → Security | true — set this last, after confirming HTTPS login works |
For allowed origins there is a shortcut: set ALLOWED_ORIGINS in docker/.env, restart, then press
Reload from environment in Admin → Security. That is the one place where env is allowed to
overwrite the stored value. It covers CORS only — the redirect URI and cookie flag still have to be
edited directly.
Then update the redirect URI in the Enable Banking portal to match. Once HTTPS works from every
device you care about, close the HTTP door with the no-http overlay from 3d.
You may not need the origins change at all: Caddy forwards X-Forwarded-Host/-Proto, so the
backend should recognise the request as same-origin and skip CORS entirely. Only add the origin if
you actually hit a 403.
[!WARNING] Once Secure cookies is
true, plain HTTP is no longer a usable fallback. Auth cookies are issued with theSecureflag, which browsers discard over HTTP — login returns 200 and then bounces straight back to the login screen with no error. If TLS later breaks (expired certificate, lostcaddy_datavolume, DNS change) and you need to get back in over HTTP, clear the flag directly in the database and restart:docker compose -f docker/docker-compose.yml exec db \ psql -U picsou -d picsou -c \ "update app_setting set value='false' where setting_key='app.secure-cookies';" docker compose -f docker/docker-compose.yml restart app
4. Advanced configuration (optional)
If you prefer to seed everything up front (CI, external secret managers, etc.):
cp docker/.env.example docker/.env
| Variable | When to set | Description |
|---|---|---|
COMPOSE_PROFILES |
To enable connectors or TLS | Comma-separated connector profiles (bourso,revolut), optionally including tls, or all-connectors. Empty: app and database only |
APP_SIDECAR_API_KEY |
When a connector is enabled | openssl rand -base64 32. Shared by the app and every enabled *-auth sidecar; recreate them together after a rotation |
POSTGRES_PASSWORD |
Before the first start | Strong random password, shared by the app and PostgreSQL (default picsou) |
JWT_SECRET |
Override auto-gen | openssl rand -base64 48 |
CRYPTO_ENCRYPTION_KEY |
Override auto-gen | openssl rand -base64 32 |
APP_USERNAME / APP_PASSWORD_HASH |
Skip wizard | htpasswd -bnBC 12 "" YOUR_PASSWORD | tr -d ':\r\n' |
ALLOWED_ORIGINS |
Non-localhost | e.g. https://picsou.example.com — leave blank for a same-origin deployment |
SECURE_COOKIES |
Plain HTTP | false if no TLS in front; keep true behind HTTPS |
ENABLEBANKING_* |
Skip wizard | From your Enable Banking dashboard. The redirect URI must be https:// |
BOURSO_AUTH_URL |
Custom sidecar | Defaults to http://bourso-auth:8001 |
CAISSE_EPARGNE_AUTH_URL |
Custom sidecar | Defaults to http://caisse-epargne-auth:8001 |
BOURSE_DIRECT_AUTH_URL |
Custom sidecar | Defaults to http://bourse-direct-auth:8001 |
FORTUNEO_AUTH_URL |
Custom sidecar | Defaults to http://fortuneo-auth:8001 |
CMES_AUTH_URL |
Custom sidecar | Defaults to http://cmes-auth:8001. Set it to reach a cmes-auth sidecar run elsewhere (Crédit Mutuel / CIC Épargne Salariale) |
CIC_AUTH_URL |
Custom sidecar | Defaults to http://cic-auth:8001. Set it to reach a cic-auth sidecar run elsewhere (CIC online banking) |
PICSOU_DOMAIN |
TLS profile | Hostname Caddy serves — see step 3 |
HSTS_ENABLED |
Trusted cert | true only with a publicly-trusted certificate |
SSO_ISSUER_URI |
Single sign-on | OIDC issuer, exactly as the IdP discovery document spells it. SSO is on when this and SSO_CLIENT_ID are set. No default. An invalid value logs an error and leaves SSO off. Register the redirect URI https://<host>/login/sso/callback at the IdP |
SSO_CLIENT_ID |
Single sign-on | Client ID registered at the IdP. No default |
SSO_CLIENT_SECRET |
Confidential client | Blank (default): public client, PKCE only. Set: client_secret_basic plus PKCE |
SSO_PROVIDER_NAME |
Single sign-on | Button label ("Sign in with Zitadel"), at most 40 characters. Defaults to SSO |
SSO_SCOPES |
Single sign-on | Defaults to openid,profile,email; openid is always added. Add groups for IdPs that need it (Pocket ID, Authelia), but not with Zitadel and its groups Action |
SSO_GROUPS_CLAIM |
Single sign-on | Literal claim name holding the groups, e.g. urn:zitadel:iam:org:project:roles. Defaults to groups |
SSO_ADMIN_GROUP |
SSO admins | Turns on the SSO first admin, admin provisioning and role sync. While set, the password first-admin form is refused. No default |
SSO_SESSION_REVALIDATE_AFTER |
Single sign-on | Maximum age of an SSO session before it must pass through the IdP again. Defaults to 24h |
SSO_IDP_LOGOUT |
Single sign-on | true also ends the IdP session on logout (RP-initiated logout with client_id and post_logout_redirect_uri; register https://<host>/login as post-logout URI). Defaults to false |
Single sign-on, the admin group and a step-by-step Zitadel setup are described in docs/features/sso-login.md.
Note: The bcrypt hash contains
$characters. In.env, write it as-is without quotes. Never export it in a shell without single quotes:export APP_PASSWORD_HASH='$2a$12$...'.
Settings the wizard already stored win over
.env.ENABLEBANKING_REDIRECT_URI,ALLOWED_ORIGINS, andSECURE_COOKIESresolve database-first, so once setup has completed, changing them here has no effect — use the Admin page instead (see 3f).
5. Enable Banking key setup (optional)
mkdir -p docker/secrets
openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 -out docker/secrets/enablebanking.pem
openssl rsa -pubout -in docker/secrets/enablebanking.pem -out enablebanking_public.pem
Upload enablebanking_public.pem to your Enable Banking dashboard.
Kubernetes (Helm)
A Helm chart in charts/picsou deploys the same images on Kubernetes:
the app, the sidecars, an optional single-instance PostgreSQL, network policies, and a pre-upgrade
migration Job. TLS terminates on your Ingress or Gateway.
helm install picsou oci://ghcr.io/cloeille/charts/picsou -n picsou --create-namespace -f values.yaml
Each release publishes the chart under its version; every push to main publishes a pre-release
(--devel) pinned to that commit's images.
Another machine
Git holds the code, not your data or keys. To move an install, copy the Docker volumes as described in Moving the stack to another machine.
Development
Contributions are welcome: bug fixes, features, translations or documentation. Commands, conventions, the HTTPS dev certificate and the pull-request checklist are in CONTRIBUTING.md. Keep pull requests to one concern and about 600 changed lines, synced with the release branch they target and green before review; open an issue first for a feature or a new bank connector. CI is described in docker-deployment.md.
Security
See SECURITY.md for the vulnerability reporting policy.
License
Apache 2.0 + Commons Clause — free for personal use and managed hosting. Commercial SaaS use is prohibited without permission.
