A personal budget tracker split into independently deployed services, plus a daily one-shot job that imports bank transactions from email. Everything is wired together by docker-compose.yml and reverse-proxied by nginx.
- backend-service (FastAPI, port
8502) — owns the SQLite database (data/budget.db, tablesbudget_set,budget_trackerandemail_transactions). Exposes REST routes under/summary,/budget,/transactions,/query,/classifyand/email-transactions, and also mounts an MCP server at/mcp(viafastapi-mcp) so selected routes can be called as tools./email-transactionsis deliberately not one of them. - agent-service (FastAPI + LangGraph, port
8000) — "Penny", an OpenRouter-backed LangChain agent that consumes backend-service's tools over MCP. Conversation state is checkpointed to Postgres. Data-mutating tools (adding/updating/deleting transactions or budgets) require human-in-the-loop confirmation; read-only tools do not. - frontend-service — the static
app.htmlUI, served by nginx. Tabs: Summary, Spend, Budget, Bank (email-sourced, read-only) and Penny. - email-ingest-service — a one-shot job, not a server. It reads HDFC bank alert emails from Gmail, has an LLM extract each transaction, and POSTs them to backend-service. Host cron runs it daily. See Email-sourced bank transactions.
- nginx — reverse proxy that routes
/chat/*→ agent-service,/summary|budget|transactions|query|classify|mcp|docs|redoc|openapi.json→ backend-service, and everything else → frontend-service./email-transactionsalso goes to backend-service but is GET-only: anything else gets a 403, because only email-ingest writes there, directly over the Docker network. Seenginx/nginx.conf.
All services (and the db_backup/ scripts) share a single root .venv (a conda env) — there is no per-service virtualenv.
conda activate /Users/himanshusingh/Developer/budget-tracker/budget_tracker_api/.venvcd backend-service
pip install -r requirements.txt
python app/main.pyRuns at http://localhost:8502, backed by the SQLite DB at backend-service/data/budget.db. Interactive API docs are at http://localhost:8502/docs, and the MCP server is mounted at http://localhost:8502/mcp.
agent-service requires a reachable Postgres instance (for LangGraph checkpointing) and an OpenRouter API key.
cd agent-service
pip install -r requirements.txt
cp .env.example .envEdit .env and set:
OPENROUTER_API_KEY— required.DATABASE_URL— pointing at a running Postgres instance (e.g.postgresql://postgres:postgres@localhost:5432/agent_service). You can start just the Postgres container from the compose file:docker compose up -d postgres.MCP_CONFIG_PATH=mcp_servers.local.json— for local (non-docker) runs, so the agent reaches backend-service athttp://localhost:8502/mcpinstead of the docker-network hostname used bymcp_servers.json.
Then start it:
python src/main.pyRuns at http://localhost:8000 by default (configurable via PORT in .env). With both services running locally, backend-service must already be up so the agent can connect over MCP.
The full stack (frontend, backend, agent, Postgres, nginx, NocoDB) is defined in docker-compose.yml. From the repo root:
cp .env.example .env # set OPENROUTER_API_KEY, NC_ADMIN_* vars (and GMAIL_* for email-ingest)
docker compose up --buildThis builds and starts six containers and exposes the whole app through nginx at http://localhost:8502; frontend-service, backend-service, agent-service, and Postgres are only reachable from each other over the internal budget-network Docker network (Postgres is additionally published on localhost:5432 for convenience). NocoDB is an admin UI for both databases, published on its own port for local/Tailscale access only — not routed through nginx. Backend data persists in the external budget_data volume and agent checkpoints persist in agent_postgres_data. The seventh service, email-ingest, sits behind the cron profile, so up never starts it; run it on demand with docker compose --profile cron run --rm email-ingest.
| Service | URL |
|---|---|
| Whole app (via nginx) | http://localhost:8502 |
| → frontend (UI) | http://localhost:8502/ |
| → agent chat | http://localhost:8502/chat |
| → backend API/MCP | http://localhost:8502/summary, /budget, /transactions, /query, /classify, /mcp, /docs |
| → email transactions (GET only) | http://localhost:8502/email-transactions?start=YYYY-MM-DD&end=YYYY-MM-DD |
| Postgres (host access) | localhost:5432 |
| NocoDB (Postgres + SQLite admin UI) | localhost:8505 |
To push built images to Docker Hub (used for deploying to the Raspberry Pi): ./docker_push.sh <tag>.
The Pi runs the same docker-compose.yml stack described above, pulling
pre-built images from Docker Hub instead of building locally. nginx is the
only container with a port published to the public internet — Postgres and
NocoDB publish ports on the Pi host too, but those are reachable only over
Tailscale, not the public internet.
flowchart LR
user["Browser / Penny client"] -->|":8502"| nginx
subgraph pi["Raspberry Pi — docker compose"]
nginx["nginx"]
frontend["frontend-service<br/>static app.html"]
backend["backend-service<br/>FastAPI + MCP"]
agent["agent-service<br/>LangGraph 'Penny'"]
pg["postgres:16<br/>checkpoints"]
vol[("budget_data volume<br/>budget.db")]
ingest["email-ingest<br/>one-shot, cron profile"]
nginx -->|"/ (default)"| frontend
nginx -->|"/summary /budget /transactions<br/>/query /classify /mcp /docs"| backend
nginx -->|"/email-transactions<br/>(GET only)"| backend
nginx -->|"/chat/*"| agent
agent -.->|"MCP tool calls"| backend
ingest -->|"POST /email-transactions<br/>(docker network, bypasses nginx)"| backend
backend --> vol
agent --> pg
end
cron["host crontab<br/>06:00 daily"] -.->|"docker compose run --rm"| ingest
gmail[("Gmail<br/>IMAP, read-only")] --> ingest
ingest <-->|"structured output"| openrouter["OpenRouter LLM"]
agent <--> openrouter
Deploying is bash .claude/skills/deploy-to-pi/deploy.sh, which drives:
sequenceDiagram
participant Dev as Dev machine
participant Hub as Docker Hub
participant Pi as Pi
Dev->>Hub: 1. docker_push.sh — build + push 5 images
Hub->>Pi: 2. docker compose --profile cron pull && up -d (recreates only changed images)
Note over Pi: 3. docker compose restart nginx — always, not conditional
Pi-->>Dev: 4. curl /app and /chat/sessions — expect 200
Why step 3 always runs: docker compose up -d only recreates containers
whose image changed — e.g. a frontend-only deploy leaves backend-service
and nginx untouched. The recreated container gets a new internal Docker
IP, but nginx resolved its upstream hostnames to IPs once at its own
startup and won't re-resolve them, so every route 502s until nginx is
restarted — even though docker compose ps shows everything "Up".
Why --profile cron on pull: email-ingest is behind the cron profile so
that up never starts it. A plain docker compose pull skips it too, and the
Pi would keep running a stale ingester image.
The Bank tab shows every transaction your bank emailed you about. It is kept
separate from the manually curated spend in the Spend tab, so you can
reconcile the two. The data comes from email-ingest-service, a short script
that runs once a day and exits. There's no agent loop, just one LLM call per
email:
flowchart LR
cron["host crontab 06:00"] --> run["docker compose --profile cron<br/>run --rm email-ingest"]
run --> search["IMAP search (read-only)<br/>from:hdfcbank -smartstatement<br/>subject:"Credit Card"<br/>newer_than:2d"]
search --> llm["per email: LLM with_structured_output<br/>→ is_transaction, amount, direction,<br/>type, description, account, date"]
llm -->|"non-transactions dropped"| post["POST /email-transactions<br/>backend-service:8502"]
post --> table[("email_transactions<br/>UNIQUE MessageId")]
- Idempotent: every row stores the email's
Message-ID, and the POST isINSERT OR IGNORE. The 2-day lookback (LOOKBACK_DAYS) means a missed run is picked up the next day with no duplicates. - Read-only mail access: the script uses stdlib
imaplib, opens the mailbox withEXAMINE, and fetches withBODY.PEEK[], so alerts are never marked as read. It authenticates with a Gmail app password created only for this job, so you can revoke it without affecting anything else. - Amounts are signed: debits are negative and credits positive.
Typeis one ofUPI,Credit Card,Debit Card,Net BankingorOther. A UPI payment made with the RuPay credit card counts asUPI. - Penny can read it through
run_query, but has no tool to change it.
Configuration lives in the root .env: GMAIL_USER, GMAIL_APP_PASSWORD,
OPENROUTER_API_KEY and EXTRACT_MODEL (default google/gemini-2.5-flash).
To run it locally against a natively running backend-service:
cd email-ingest-service
pip install -r requirements.txt
cp .env.example .env # fill in, BACKEND_URL=http://localhost:8502
set -a; source .env; set +a; python src/ingest.pyOn the Pi, the crontab line is:
0 6 * * * cd ~/budget-tracker && docker compose --profile cron run --rm email-ingest >> email-ingest.log 2>&1
db_backup/ is not a disaster-recovery tool for the Pi — the Pi keeps
its own Docker volume and isn't restored via this repo. Instead it's for
pulling the latest prod data snapshot down locally so you can test new
features against real data.
backup_to_s3.shis a reference copy of the script that actually runs nightly via cron on the Pi (~/budget-tracker/budget-api-db-backup.sh) — it dumpsbudget.dbout of the runningbackend-servicecontainer and uploads it to S3. It isn't meant to be run from a dev machine; keep the two copies in sync by hand if you change one.restore_latest_backup.pypulls the most recent S3 backup and restores it locally. Requires theawsCLI to already be authenticated (aws login), since it shells out toaws s3.
flowchart LR
subgraph nightly["Nightly cron, on the Pi"]
direction LR
c1["backend-service<br/>container"] -->|"docker cp"| tmp["host /tmp"]
tmp -->|"aws s3 cp"| s3[("s3://…-backups/<br/>backup_<timestamp>/budget.db")]
end
subgraph manual["Manual, on a dev machine"]
direction RL
s3b[("s3://…-backups/<br/>latest prefix")] -->|"aws s3 ls"| restore["restore_latest_backup.py"]
restore --> dbfile["backend-service/data/budget.db"]
restore --> sandbox[".sandbox db (if present)"]
restore --> csv["csv_exports/ (regenerated)"]
end
The restore path never writes back to the Pi — it's for pulling a prod-shaped dataset down to test against locally, not for recovering the Pi's own volume.
aws login # if your session has expired
python db_backup/restore_latest_backup.pyThis restores backend-service/data/budget.db (backing up whatever was
already there to budget.db.bak-<timestamp> first), also refreshes the
run-budget-tracker-api skill's sandboxed DB copy if present, and
regenerates CSVs into db_backup/csv_exports/ for a quick human-readable
look at the data.
If you're running the stack via docker compose, this restore alone
has no effect on a running container: backend-service/data is mounted
from the named budget_data volume, which only ever seeds from the local
data/ folder once, the first time the (empty) volume is created — after
that it's fully decoupled from the file on disk. To push a freshly
restored DB into an already-running container:
docker cp backend-service/data/budget.db $(docker compose ps -q backend-service):/app/data/budget.db
docker compose restart backend-serviceclassifier-training/ retrains the model behind /classify (classify_description
in the MCP tool list) — the MiniLM embedder (int8 ONNX, via onnxruntime) + classifier pair that suggests
a category from a free-text transaction description. It reads the CSV export
produced by the data-pulling step above, so run that first if you want to train
on the latest prod data:
flowchart LR
csv["db_backup/csv_exports/<br/>budget_tracker.csv"] -->|"train.py"| artifacts["backend-service/models/<br/>classifier.joblib + label_encoder.joblib"]
artifacts -->|"docker build<br/>COPY models/"| image["backend-service image"]
artifacts -.->|"test.py<br/>(local sanity check)"| dev["dev machine"]
python db_backup/restore_latest_backup.py # optional — pulls the latest data first
python classifier-training/train.py # retrains, overwrites backend-service/models/*.joblib
python classifier-training/test.py # sanity-check predictions locallyTraining is deterministic (fixed RANDOM_STATE), so re-running it against
unchanged data produces byte-identical model files — a no-op in git diff.
Retraining is manual only; there's no cron job for it. A running
backend-service caches the model in-process, so a locally running container
needs a restart (see docker cp/restart above, targeting
backend-service/models/ instead of data/) and the Pi needs a redeploy
(deploy-to-pi skill) to pick up a newly trained model.