budgetmate.app

BudgetMate

Family finance for Indian households - Go, Templ and HTMX in a distroless container. Zero maintenance, zero framework, zero data leaving the house.

Timeline
2025
Role
Solo build
Team
Solo
Status
shipped

Technology Stack

GoGoTemplHTMXHTMXSQLiteSQLiteDocker (distroless)Docker (distroless)

✦Overview

BudgetMate is a family finance dashboard built on one uncomfortable premise: the tools that manage your money should not need the internet, a subscription, or a frontend framework's worth of maintenance. It is a ledger - so it is built like one.

The whole app compiles to a single static Go binary and ships in a distroless container. Your bank data is parsed in memory and never leaves your hardware. It runs the same on a ₹500 Raspberry Pi in a cupboard as it does on a cloud VM - zero data leaving the house is the feature, not a footnote.

✦Key Features

  • One static binary, zero maintenance - The entire app is a single self-contained Go executable - no runtime, no node_modules, no database server to babysit. Shipped at v1.0.0 and production-ready.

  • Zero-knowledge CSV import - Bank statements are parsed entirely in memory and mapped to transactions - the raw file is never persisted. Your statement never becomes a liability sitting in a database.

  • Hypermedia UI, no framework - Templ + HTMX give an SPA feel with smooth transitions, but endpoints return HTML fragments (HATEOAS) - no React, no bundler, perfect SEO and a tiny payload.

  • Calm AI nudges - Financial insights that respect attention instead of gamifying anxiety - nudges, not notifications-as-slot-machine.

✦Technical Implementation

01Stack & architecture

Go 1.22 for compiled performance and a single executable. SQLite through the pure-Go modernc driver - zero CGO, so cross-compilation is trivial. Vertical-slice architecture: each feature owns its logic and its Templ views, so a change stays local instead of rippling across layers. Every one of these calls is documented in an ADR, not folklore.

02Distroless container

Only the compiled binary ships - no shell, no package manager, no OS userland. That is roughly a 95% smaller attack surface than a typical node/python image, and nothing to patch on a Tuesday.

Dockerfile
dockerfile
# build a fully static binary
FROM golang:1.22 AS build
WORKDIR /src
COPY . .
RUN CGO_ENABLED=0 go build -o /budgetmate ./cmd/web

# ship only the binary — no shell, no package manager
FROM gcr.io/distroless/static-debian12
COPY --from=build /budgetmate /budgetmate
USER nonroot:nonroot
ENTRYPOINT ["/budgetmate"]

✦Project Structure

structure
budgetmate/
├─ cmd/web/          # main() — wires the router, one binary
├─ internal/
│  ├─ budget/        # vertical slice: logic + Templ views
│  ├─ ledger/        # transactions, CSV import (in-memory)
│  ├─ insights/      # calm AI nudges
│  └─ store/         # SQLite (modernc, pure-Go, no CGO)
├─ web/              # Templ components + HTMX fragments
├─ Dockerfile        # distroless, static binary
└─ docs/adr/         # architecture decision records

✦Design Decisions

HTMX over a SPA framework

For a ledger, a React SPA is maintenance debt: a build pipeline, a bundle, and hydration for pages that are 90% forms and tables. HTMX returns HTML fragments over the wire - the server stays the source of truth, and there is no JS framework to keep upgrading.

Pure-Go SQLite (modernc), no CGO

CGO makes cross-compilation painful and ties builds to a C toolchain. The modernc SQLite driver is pure Go, so a single `go build` produces a static binary for any target - Pi or cloud - with no external database process.

Local-first, in-memory parsing

The privacy guarantee is architectural: statements are parsed in memory and discarded, and the whole app runs on the user's own hardware. There is no server to breach because there is no shared server.

✦Challenges

↳

SPA feel without a SPA

Getting smooth, app-like transitions out of server-rendered fragments took care with HTMX swaps and Templ component boundaries - the reward is an interface that feels modern with none of the client-side machinery.

↳

Trustworthy CSV import

Bank exports are inconsistent and messy. Parsing them reliably in memory - without ever writing the raw file to disk - meant building a tolerant column-mapping step that fails loudly rather than silently corrupting the ledger.

Want the full walkthrough - architecture, decisions, war stories?

ask me about it