Skip to content
Back to projects
In productioncaudalwallet.com

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
Caudal open on a phone at caudalwallet.com, showing the small-expenses panel with the categories Viaje, Hogar, Salud and Supermercado, and a 46% increase over last month. Amounts are blurred.
Caudal open on a phone at caudalwallet.com, showing the small-expenses panel with the categories Viaje, Hogar, Salud and Supermercado, and a 46% increase over last month. Amounts are blurred.

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.

  1. Edge

    Caddy

    The only exposed port (443). Issues and renews TLS on its own.

  2. Application

    gunicorn + Django

    No ports published to the host: Caddy reaches it by service name.

  3. 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