A self-hosted personal finance dashboard for tracking net worth, savings rate, and asset distribution. Built for fast monthly data entry (<5 min/month) with flexible visualizations.
Security Notice
Ledger has no built-in authentication. It is designed for use on a trusted local network or behind a VPN. Do not expose this application to the public internet without first adding authentication (e.g. HTTP Basic Auth in Nginx, Authelia, or a self-hosted VPN like WireGuard/Tailscale). Exposing your personal financial data without a password is a significant security risk.
- Net worth tracking across assets and liabilities with automatic monthly metrics calculation
- Monthly data entry with per-account balance snapshots and inline editing
- Spending tracking by account/card (income and expenses) with auto-calculated savings rate
- Projections / Path to FI — compound-growth forecast to a target net worth with configurable contribution and return rate assumptions
- Asset allocation & drift report — target vs. actual allocation across asset classes with rebalancing guidance
- Holdings tracking — record individual stock/fund positions per account for allocation drill-down
- CSV import for historical data with flexible month header parsing (
Jan '24,Jan 2024,2024-01, etc.) - CSV export — download all monthly snapshots and spending data as a spreadsheet
- S&P 500 benchmark comparison — overlay index returns on your net worth chart to see how you track against the market
- Account history charts — per-account balance history via Plotly with dark-mode support
- Recurring entries — mark spending entries as recurring so they pre-populate in new months
- Onboarding wizard — guided first-run setup flow to create accounts and seed initial balances
- Import audit log tracking all CSV import history
To see the app populated with realistic demo data (~$120k–$212k net worth trajectory over 24 months), run the seed script after setup:
python scripts/seed_demo.py- Backend: Python 3, Flask 3.0, SQLAlchemy 2.0, Flask-Migrate
- Database: SQLite (local, zero-config)
- Frontend: Bootstrap 5, Plotly 5.18, vanilla JS
- Data processing: pandas 2.2
- Python 3.10+
- Git
git clone https://github.com/your-username/ledger.git
cd ledger
python3 -m venv venv
source venv/bin/activate # Windows: venv\Scripts\activate
pip install -r requirements.txtGenerate a secret key:
python3 -c "import secrets; print(secrets.token_hex(32))"Create a .env file in the project root:
SECRET_KEY=<paste-generated-key-here>
DATABASE_URL=sqlite:///data/finance.db
FLASK_ENV=development
flask db upgradepython run.pyApp runs at http://localhost:5001 and creates data/finance.db on first launch.
For always-on deployment, see the Docker or Raspberry Pi + Nginx sections below.
python run.pyis for local development only.
Go to /import and upload a CSV in this format:
Category,Jan '24,Feb '24,...
Cash,"$25,000","$26,500",...
Retirement,"$150,000","$152,000",...
Investments,"$75,000","$76,000",...
Real Estate,"$400,000","$400,000",...
Mortgage,"$300,000","$299,000",...
Income,"$8,000","$8,000",...
Expenses,"$5,500","$5,200",...
Accepted month header formats: Jan '24, Jan 2024, January 2024, 2024-01, 01/2024
CSV import is idempotent — re-importing the same data upserts without duplicating.
ledger/
├── app/
│ ├── __init__.py # Flask app factory
│ ├── models.py # SQLAlchemy models
│ ├── routes.py # Page routes + REST API
│ ├── import_processor.py # CSV parsing and import logic
│ ├── templates/ # Jinja2 HTML templates
│ └── static/ # CSS and JS assets
├── data/ # SQLite DB lives here (gitignored)
├── logs/ # App logs (gitignored)
├── migrations/ # Flask-Migrate migrations
├── scripts/ # Utility scripts
├── tests/ # Test suite
├── .env # Environment variables (gitignored)
├── requirements.txt
└── run.py # Entry point (port 5001)
| Table | Purpose |
|---|---|
accounts |
Financial accounts (Cash, Retirement, Investments, Real Estate, Mortgage) |
account_snapshots |
Monthly balance per account — one row per account per month |
spending_entries |
Income and expenses tracked by card/account name |
asset_allocations |
Asset class distribution per investment account |
calculated_metrics |
Pre-computed monthly totals (net worth, save rate, monthly change) |
app_settings |
Key/value config store (e.g. target allocation percentages) |
import_logs |
Audit trail for CSV imports |
Metrics are automatically recalculated whenever a snapshot or spending entry is written. All currency values use Numeric(12,2) to avoid floating point errors. Dates are stored as the first day of the month.
Ledger is a local-network application. Before exposing it on any network:
- It has no login screen, session management, or user accounts
- Anyone who can reach the app's IP and port can read and modify all your financial data
- Acceptable deployments: home LAN accessible only from trusted devices, or behind a VPN (WireGuard, Tailscale, etc.)
- Unacceptable: forwarding port 80 or 5001 to the public internet without authentication
- If you want browser access from outside your home network, use a VPN rather than port-forwarding
Optional hardening: add HTTP Basic Auth to Nginx as a lightweight password layer for LAN use.
The fastest way to run Ledger on any machine with Docker installed.
1. Generate a secret key:
python3 -c "import secrets; print(secrets.token_hex(32))"2. Create a .env file (or copy .env.example):
SECRET_KEY=<paste-generated-key-here>
DATABASE_URL=sqlite:////app/data/finance.db
FLASK_ENV=production
3. Start the container:
docker compose up -dOpen http://localhost:5001 in your browser. Data is persisted in ./data/finance.db on your host machine. To stop: docker compose down.
To update after a code change: docker compose build && docker compose up -d.
Deploy Ledger as a persistent web server on a Raspberry Pi using Gunicorn (app server) and Nginx (reverse proxy). This replaces Flask's built-in dev server with a production-grade setup.
Browser → Nginx (port 80) → Unix Socket → Gunicorn → Flask app
- Nginx handles incoming HTTP requests, serves static files, and proxies dynamic requests to Gunicorn via a Unix socket.
- Gunicorn is a production WSGI server that runs your Flask app with multiple worker processes.
- systemd keeps Gunicorn running as a background service that restarts on failure or reboot.
- Unix socket is used instead of a TCP port for faster, more secure local communication between Nginx and Gunicorn.
- Raspberry Pi running Raspberry Pi OS (Debian-based, 64-bit recommended)
- Python 3.10+
- Git installed
- SSH access to the Pi (or keyboard/monitor attached)
Install system dependencies including OpenBLAS, which is required by numpy/pandas on ARM:
sudo apt update && sudo apt upgrade -y
sudo apt install -y python3-pip python3-venv git nginx libopenblas-devWhy
libopenblas-dev? Numpy (used by pandas) requires OpenBLAS as a system library on Raspberry Pi. Without it, the app will fail to start withlibopenblas.so.0: cannot open shared object file.
cd /home/your-username
git clone https://github.com/your-username/ledger.git
cd ledgerCreate the venv and install dependencies. Because /tmp is RAM-backed and limited on the Pi, redirect pip's temp directory to disk to avoid No space left on device errors:
python3 -m venv venv && source venv/bin/activate
pip install --upgrade pip
mkdir -p /home/your-username/tmp
TMPDIR=/home/your-username/tmp pip install -r requirements.txt
TMPDIR=/home/your-username/tmp pip install gunicornNote:
/tmpon Raspberry Pi OS is atmpfs(RAM-backed), capped at ~214MB. Large pip installs will fail if it fills up. UsingTMPDIRredirects scratch space to the SD card instead.
Generate a strong secret key first:
python3 -c "import secrets; print(secrets.token_hex(32))"Copy the output, then create the .env file:
nano /home/your-username/ledger/.envAdd the following, pasting your generated key:
SECRET_KEY=<paste-generated-key-here>
DATABASE_URL=sqlite:////home/your-username/ledger/data/finance.db
FLASK_ENV=production
Ensure the data and logs directories exist:
mkdir -p /home/your-username/ledger/data
mkdir -p /home/your-username/ledger/logsNginx runs as www-data and needs execute permission on your home directory to traverse into it and reach the Unix socket:
sudo chmod o+x /home/your-usernameSkip this and you'll get a 502 Bad Gateway even when Gunicorn is running and the socket exists.
cd /home/your-username/ledger
source venv/bin/activate
flask db upgradeBefore setting up the service, verify Gunicorn can serve the app:
cd /home/your-username/ledger
source venv/bin/activate
/home/your-username/ledger/venv/bin/gunicorn --bind 0.0.0.0:5001 --workers 2 "run:app"Visit http://<pi-ip-address>:5001 in your browser. If the app loads, kill it with Ctrl+C and continue.
Always use the full venv path (
/home/your-username/ledger/venv/bin/gunicorn) rather than justgunicorn, to ensure it uses the venv's Python where all dependencies are installed.
Worker count: 2 workers is a safe default for a Pi. The general formula is
(2 × CPU cores) + 1, but Pi resources are limited.
This makes Gunicorn start automatically on boot and restart on failure.
sudo nano /etc/systemd/system/ledger.servicePaste the following, replacing your-username with your actual username:
[Unit]
Description=Ledger - Personal Finance Dashboard
After=network.target
[Service]
User=your-username
Group=www-data
WorkingDirectory=/home/your-username/ledger
Environment="PATH=/home/your-username/ledger/venv/bin"
EnvironmentFile=/home/your-username/ledger/.env
ExecStart=/home/your-username/ledger/venv/bin/gunicorn \
--workers 2 \
--bind unix:/home/your-username/ledger/ledger.sock \
--access-logfile /home/your-username/ledger/logs/access.log \
--error-logfile /home/your-username/ledger/logs/error.log \
run:app
Restart=always
[Install]
WantedBy=multi-user.targetEnable and start the service:
sudo systemctl daemon-reload
sudo systemctl enable ledger
sudo systemctl start ledger
sudo systemctl status ledgerYou should see active (running) in the output.
Security reminder: This Nginx config listens on port 80 with no authentication. It is safe on a private LAN or VPN. Do not forward this port to the public internet.
Create a new Nginx site config:
sudo nano /etc/nginx/sites-available/ledgerPaste the following, replacing your-username with your actual username:
# Rate limiting: allow 10 requests/second per IP, burst of 20
limit_req_zone $binary_remote_addr zone=ledger:10m rate=10r/s;
server {
listen 80;
server_name _;
# Security headers
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
location /static/ {
alias /home/your-username/ledger/app/static/;
expires 30d;
add_header Cache-Control "public, immutable";
}
location / {
limit_req zone=ledger burst=20 nodelay;
include proxy_params;
proxy_pass http://unix:/home/your-username/ledger/ledger.sock;
proxy_read_timeout 120s;
proxy_connect_timeout 10s;
}
}Enable the site and remove the default:
sudo ln -s /etc/nginx/sites-available/ledger /etc/nginx/sites-enabled/
sudo rm /etc/nginx/sites-enabled/default
sudo nginx -t # Test config — should print "ok"
sudo systemctl restart nginx
sudo systemctl enable nginx- Find your Pi's local IP address:
hostname -I - On any device on the same network, open
http://<pi-ip-address>in a browser. - Ledger should load via port 80 (no port number needed in the URL).
To always reach the Pi at the same address, configure a DHCP reservation in your router's admin panel using the Pi's MAC address. This is easier than configuring a static IP on the Pi itself.
Install avahi-daemon so you can reach the Pi by name instead of IP:
sudo apt install -y avahi-daemonTo set a custom hostname (e.g. access via http://ledger.local):
sudo hostnamectl set-hostname ledger
sudo systemctl restart avahi-daemonPi-hole compatibility: Pi-hole and Ledger can run on the same Pi without conflict. Pi-hole uses port 80 for its own admin UI by default — if you install both, configure Pi-hole to use a different port (e.g. 8080) before setting up Nginx for Ledger.
If the Pi reboots, both Nginx and the Ledger service are set to start automatically via systemd (enable was run during setup). You shouldn't need to do anything.
If something isn't working after a reboot, run these in order:
# 1. Check if Gunicorn is running
sudo systemctl status ledger
# 2. Check if Nginx is running
sudo systemctl status nginx
# 3. If either is stopped, restart it
sudo systemctl start ledger
sudo systemctl start nginx
# 4. If you made code or config changes, do a full restart
sudo systemctl restart ledger
sudo systemctl reload nginxTo manually restart everything at once:
sudo systemctl restart ledger && sudo systemctl reload nginxcd /home/your-username/ledger
git pull origin main
source venv/bin/activate
TMPDIR=/home/your-username/tmp pip install -r requirements.txt # If dependencies changed
flask db upgrade # If models changed
sudo systemctl restart ledger# View Gunicorn service status
sudo systemctl status ledger
# View application logs
tail -f /home/your-username/ledger/logs/error.log
tail -f /home/your-username/ledger/logs/access.log
# View Nginx logs
sudo tail -f /var/log/nginx/error.log
# Check socket exists and permissions
ls -la /home/your-username/ledger/ledger.sock| Symptom | Check |
|---|---|
| 502 Bad Gateway | Run sudo systemctl status ledger — is Gunicorn running? Also run sudo chmod o+x /home/your-username (home directory permissions is a common cause) |
No module named 'flask' on startup |
Dependencies not installed in venv. Run TMPDIR=/home/your-username/tmp /home/your-username/ledger/venv/bin/pip install -r requirements.txt |
libopenblas.so.0 error |
Run sudo apt install -y libopenblas-dev then sudo systemctl restart ledger |
No space left on device during pip install |
/tmp is full (RAM-backed). Use TMPDIR=/home/your-username/tmp pip install ... instead |
| App loads but no styles | Verify the /static/ alias path in Nginx matches your actual static folder |
| Can't reach Pi from network | Confirm Pi's IP with hostname -I; check your router firewall isn't blocking port 80 |
| Database errors after update | Run flask db upgrade then sudo systemctl restart ledger |
When models change:
flask db migrate -m "description"
flask db upgrade| Method | Endpoint | Description |
|---|---|---|
| GET | /api/networth-history |
Net worth time series |
| GET | /api/account-balances/<id> |
Balance history for one account |
| GET | /api/months |
List of all months with summary metrics |
| POST | /api/months |
Create a new month |
| DELETE | /api/months/<YYYY-MM> |
Delete all data for a month |
| POST | /api/snapshots |
Create or update an account snapshot |
| PUT | /api/snapshots/<id> |
Update snapshot balance |
| DELETE | /api/snapshots/<id> |
Delete a snapshot |
- Port is 5001 (not 5000) — changed to avoid conflict with macOS AirPlay
- Uses Flask application factory pattern (
create_app()) - CSV import is idempotent — re-importing the same data upserts without duplicating
.csvfiles anddata/finance.dbare gitignored- Run tests with
pip install -r requirements-dev.txt && pytest
The Holdings feature can use the Claude API to auto-classify tickers into asset classes. To enable it, add your Anthropic API key in Settings. The key is stored in plaintext in data/finance.db — rotate it at console.anthropic.com if your data directory is ever accessed by an unauthorized party. Each new ticker lookup costs ~$0.001–$0.01; results are cached so the same ticker is only classified once.
MIT




