Self-Hosting Personal Finance with Actual Budget on Kubernetes

Actual Budget on Kubernetes, CSV-based finance collection, and why your financial data should live in your own database alongside everything else you track.

Self-Hosting Personal Finance with Actual Budget on Kubernetes

I moved away from YNAB when the price hit $170/year. Not because it was bad software (it was great) but because paying increasing subscription fees to access my own financial data felt wrong. Actual Budget is the open-source, local-first alternative I switched to, and running it on Kubernetes turned out to be one of the simplest deployments in my homelab.

Why Local-First Matters for Finance

Financial data is the most sensitive category of personal information I track. Account balances, spending categories, transaction histories: this is data that should never touch someone else's server unless I explicitly choose to put it there.

Actual Budget stores everything in a local SQLite file. The server component syncs between devices, but the database is yours. You can export it, back it up, inspect it with any SQLite tool, and delete it with confidence that no cloud copy persists. That's the trust model I want for financial data.

Compare this to YNAB: your financial data lives on their servers, governed by their privacy policy, secured by their engineering team. They've never had a breach (that I know of), but I'd rather not find out the hard way.

Running Actual Budget on Kubernetes

The deployment is minimal:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: actual-budget
  namespace: actual-budget
spec:
  replicas: 1
  selector:
    matchLabels:
      app: actual-budget
  template:
    spec:
      securityContext:
        runAsNonRoot: true
        runAsUser: 1000
      containers:
      - name: actual-budget
        image: actualbudget/actual-server:24.12.0
        ports:
        - containerPort: 5006
        volumeMounts:
        - name: data
          mountPath: /data
        resources:
          requests:
            cpu: 25m
            memory: 64Mi
          limits:
            cpu: 200m
            memory: 128Mi
      volumes:
      - name: data
        persistentVolumeClaim:
          claimName: actual-budget-data

That's essentially it. Actual Budget's server is a Node.js process that serves the web UI and handles device sync. No external database, no Redis, no background workers. The data directory contains the SQLite file and user configuration.

Resource usage is minimal: 25m CPU request and 64Mi memory at idle. It spikes slightly when syncing or importing transactions, but it's one of the lightest workloads in the cluster.

Authentication is handled by ForwardAuth via Traefik and Authentik. Actual Budget has its own password authentication, but putting it behind SSO means one fewer password to manage.

The Finance Collector

Actual Budget doesn't expose a REST API that external tools can query. It's designed as a local-first application: the sync server exists for multi-device access, not for automation.

The integration with Life Hub uses the simplest possible interface: a CSV file.

class FinanceCollector(BaseCollector):
    name = "finance"

    @property
    def ingest_path(self) -> str:
        return "/api/v1/ingest/finance"

    async def collect(self) -> list[dict[str, Any]]:
        csv_path = os.environ.get("FINANCE_CSV_PATH", "").strip()
        if not csv_path:
            logger.info("[finance] FINANCE_CSV_PATH not set - skipping")
            return []

        records = []
        with open(csv_path, newline="", encoding="utf-8") as f:
            reader = csv.DictReader(f)
            for row in reader:
                records.append({
                    "date": row.get("date", "").strip(),
                    "account_name": row.get("account_name", "").strip(),
                    "balance": float(row["balance"]) if row.get("balance") else None,
                    "category_totals": json.loads(cat_str) if cat_str else None,
                })

The CSV format is intentionally simple:

date,account_name,balance,category_totals
2026-03-01,Checking,4250.00,"{""groceries"":180.50,""utilities"":120.00}"
2026-03-01,Savings,12000.00,
2026-03-01,Investment,45000.00,

Export from Actual Budget (or any spreadsheet), set the FINANCE_CSV_PATH environment variable, and the collector picks it up on the next 15-minute cycle.

Why CSV Instead of an API

I could have built a more sophisticated integration: an Actual Budget plugin, a database reader, a sync protocol adapter. I chose CSV for three reasons:

  1. Universal compatibility. Every financial tool can export CSV. If I switch from Actual Budget to something else, the collector still works. If I want to add data from a bank export, I drop a CSV file. The integration isn't coupled to any specific tool.
  2. Manual control. Financial data updates are deliberate, not automatic. I don't want a 15-minute CronJob pulling real-time bank balances into a dashboard. I want to export a snapshot when I reconcile my accounts, which happens weekly. CSV makes that workflow explicit.
  3. Privacy. The CSV file sits on a local PVC. It's never transmitted to an API, never parsed by a remote service. The collector reads it locally inside the cluster.

Life Hub Finance Dashboard

Once the finance data is ingested, Life Hub provides a dedicated finance page at /finance with:

  • Net worth card: Sum of the latest balance across all accounts
  • Account balances table: Each account with its latest balance and snapshot date
  • Category spending breakdown: Grid of spending by category from the latest snapshot
  • Net worth trend chart: Line chart of total balance over time using Chart.js

The REST API supports filtering by date range and account name:

GET /api/v1/finance/?start_date=2026-01-01&end_date=2026-03-01
GET /api/v1/finance/accounts     # Latest balance per account
GET /api/v1/finance/summary      # Net worth + trend data

The MCP tool get_finance_summary() exposes the same data to Claude, so I can ask "how are my finances looking?" and get a direct answer from the database.

Deduplication

The ingest API deduplicates on (date, account_name). If I export the same CSV twice, the second import produces zero new records. If I update a balance and re-export, the existing record is updated.

This means I can leave the CSV file in place and let the collector run repeatedly without creating duplicate entries. It's the same idempotent pattern used by all nine collectors. That matters because CronJobs are inherently retry-prone.

What I Miss from YNAB

Being honest about the tradeoffs:

Bank sync. YNAB automatically imports transactions from connected bank accounts. Actual Budget supports bank sync through GoCardless (in some regions) and SimpleFIN (US), but it's more limited. I manually import most transactions, which takes 10-15 minutes per week.

Mobile app polish. YNAB's mobile app is excellent. Actual Budget has a PWA that works on mobile but isn't as polished: no push notifications for overspending, no quick-entry widget.

Reports. YNAB's reporting is more sophisticated out of the box. Actual Budget's reports are functional but basic. The Life Hub integration partially compensates for this by providing custom trend visualizations, but it's not a full replacement.

Goal tracking. YNAB's category goals ("save $500 for vacation by June") are well-implemented. Actual Budget has a similar feature but it's newer and less mature.

What Actual Budget Does Better

Price. Free and open source. YNAB is $14.17/month. Over three years, that's $510 I'm not spending.

Data ownership. SQLite file on my Longhorn volume, backed up daily to NFS. I can query it directly, export it anytime, and I know exactly where my financial data is.

Speed. Local-first means the app is fast. No API latency, no loading spinners, no "syncing" delays. Everything is local, everything is instant.

Envelope budgeting without the lock-in. The budgeting methodology is the same as YNAB (give every dollar a job, age your money) but without the subscription treadmill.

The Integration Story

The real value of running Actual Budget on Kubernetes alongside Life Hub isn't any single feature. It's the integration. Financial data flows into the same personal data warehouse that tracks my health, reading, music, code commits, and mood.

The Council of Advisors (Life Hub's AI commentary system) can see financial data in its briefings. A financial stress pattern (declining balances, increasing spending categories) shows up alongside mood and sleep data. The correlation engine can surface relationships: does spending more on dining out correlate with better mood scores? Does a savings milestone correlate with better sleep?

None of this is possible with YNAB, because YNAB's data lives on their servers, accessible only through their app. Self-hosting Actual Budget turns financial data from a siloed SaaS product into a component of a unified personal analytics system.

That's the real reason to self-host finance. Not the $14/month savings. Not the principle of data ownership. The practical benefit: your financial data becomes queryable, correlatable, and integrated with everything else you track about your life.