Caudal
Self-hosted, open source personal finance manager.
- Python 3.12
- Django 5.2
- PostgreSQL 17
- HTMX
- Alpine.js
- gunicorn
- WhiteNoise
- Docker
- uv
- pytest
- ruff

The problem it solves
Small recurring expenses — coffee, delivery, the subscription nobody cancels — never show up as a problem on a bank statement. They're diluted across hundreds of uncategorized lines. At the end of the month the money is gone and there's no way to point at where.
What it does
Caudal separates your salary from what's left, and surfaces small recurring spending as its own category. It's built for the phone: logging an expense takes two taps, because a tracker that's slow doesn't get used.
Architecture
Everything runs on my VPS, in containers. The only process listening on the internet is the reverse proxy; the app and the database talk over an internal Docker network that goes nowhere else.
Edge
Caddy
The only exposed port (443). Issues and renews TLS on its own.
Application
gunicorn + Django
No ports published to the host: Caddy reaches it by service name.
Data
PostgreSQL 17
On a separate internal network. Verified it doesn't answer from the proxy's network.
- No application or database container publishes ports: Docker writes iptables rules beneath the firewall, so a published port leaves Postgres exposed even when ufw says otherwise.
- Per-container memory and CPU limits, so a misbehaving app can't starve the others on the server.
- Daily backup on a systemd timer, and the restore is actually tested: it's restored into a throwaway database and row counts are compared against the source.
The hard part
Importing bank statements that look nothing alike
Every bank and wallet exports CSV its own way: different delimiter, different encoding, different column names, and two incompatible amount conventions (1.234,56 in Argentina, 1234.56 in the US). The importer detects all of it on its own, by column aliases and content analysis, and deduplicates by the source's identifier — or by a hash of date, amount and description when the source gives none — so re-importing the same file duplicates nothing. On top of that an editable rule engine auto-categorizes by keyword; whatever doesn't match is flagged for review instead of silently landing in the wrong category.
Technical decisions
HTMX and Alpineinstead ofa React SPA
Because The app is forms and lists. A SPA would have added a Node toolchain, a client bundle and duplicated state to solve something the server already solves. With HTMX, two-tap entry is an HTML fragment.
Postgres on my own VPSinstead ofstaying on managed Postgres
Because It cut latency and removed an external dependency, but made real backups a blocking requirement. A deliberate trade: your own database is worthless without a tested restore.
CSV importinstead ofa live Mercado Pago integration
Because I evaluated the API and it doesn't fit: it's merchant-oriented and exposes what you charge, not what you spend as the payer. Discarding it early avoided building on a false premise.
What I learned
That migrating data isn't done when the restore exits cleanly. I ended up comparing row counts across all 18 tables against the source and checking for pending migrations, because a restore that 'didn't fail' and a correct database are not the same thing.
What's next
Auto-generated recurring fixed expenses and an end-of-month projection of what's left.
In short
- Open source, MIT licensed
- Every release frozen on its own branch and tag
- Tested with pytest, linted with ruff
- Hand-maintained CHANGELOG