NOTE / TERRAIN

Actual Budget on Kubernetes: Personal Finance in My Homelab.

Actual Budget is one of the least dramatic applications in my cluster, which is exactly how I want personal finance software to behave.

It gives me a private place to organise accounts, transactions, categories and budgeting history. The workload is small. The data is not. If the application disappears for an afternoon, nothing else in the homelab falls over. If its persistent data disappears, years of useful context can disappear with it.

That difference shapes the way I operate it.

Browser
   │
   ▼
Traefik
   │
   ▼
Actual Budget server
   │
   ▼
PVC-backed data directory

Traefik handles the HTTPS entry point. Actual Budget runs as a single Kubernetes Deployment and keeps its state on a persistent volume. The result is intentionally compact: one application pod, one Service, one route and one PVC.

Why self-host a budget?

Financial data is unusually good at revealing the shape of a life. It contains routines, places, subscriptions, mistakes and plans. I do not need a complicated threat model to prefer keeping that history on infrastructure I control.

Self-hosting also makes the boundary clear:

application can be recreated from Git
financial history must be restored from backup

The first half is a deployment problem. The second is a data-protection problem. Kubernetes helps with the first half, but it does not magically solve the second.

Deployment layout

The manifests use a reusable base and a cluster-specific overlay:

actualbudget/
├── base/
│   ├── deployment
│   ├── service
│   └── namespace
├── overlays/
│   └── local/
│       ├── ingress route
│       └── persistent volume claim
└── fleet.yaml

Kustomize builds the final resources and Rancher Fleet reconciles the overlay. The application lives in its own actualbudget namespace and currently runs as one replica.

The persistent claim is 2 GiB on local-path storage. That is modest, but capacity is not the interesting limit here. The important questions are whether the volume is mounted, whether it contains the expected database, and whether a usable copy exists somewhere outside the failure domain of that volume.

Authentication and exposure

The public request path terminates at Traefik. Authelia can sit in front of the route as an additional access-control layer, while Actual Budget still owns its application-level session and data model.

I treat those as separate controls:

Traefik and Authelia decide who reaches the application
Actual Budget decides what happens inside the application

A healthy ingress is not proof that the budget is usable. After an access-control change I open the real application, complete the authentication flow and load an account. That end-to-end check covers much more than an HTTP 200 from the proxy.

Operating Actual Budget

When the service is unavailable, I start with the complete Kubernetes path:

kubectl -n actualbudget get deploy,pod,svc,pvc,ingressroute
kubectl -n actualbudget describe deploy actualbudget
kubectl -n actualbudget logs deploy/actualbudget --tail=200

Then I check the volume:

kubectl -n actualbudget get pvc
kubectl -n actualbudget describe pvc actualbudget

The most useful failure split is simple:

pod Pending
  └── scheduling or volume binding

pod restarting
  └── application startup, permissions or damaged state

pod Ready, page unavailable
  └── Service, Traefik or authentication path

page loads, data missing
  └── wrong or empty volume mounted

That last case deserves care. Restarting a pod attached to an empty volume only produces a healthy empty application more efficiently.

Backup and recovery

The useful backup unit is the application data directory, not the Deployment manifest. Git already contains the resources needed to recreate the pod and route.

My recovery sequence is:

1. Restore the persistent data
2. Restore any required runtime secret inputs
3. Reconcile the local overlay
4. Wait for the pod and volume to become ready
5. Sign in and open real budget data

The service is a lower operational tier than ingress or authentication, so a short outage is acceptable. Data loss is not. The target is a daily snapshot and a recovery measured in hours rather than minutes.

A snapshot is only a recovery mechanism after it has been restored at least once. For this service, a good restore test is not merely seeing files on disk. It is opening the application and confirming that recent accounts, transactions and categories are coherent.

Upgrades and the latest tag

The current overlay tracks the latest application image. That is convenient, but it also means the exact application version is not fully expressed by Git.

This is technical debt I prefer to describe honestly:

latest tag
  ├── easier unattended updates
  └── weaker reproducibility and rollback

Before a meaningful upgrade, the safe order is snapshot first, change second, validate third. If the new image changes persistent state, rolling the Deployment back may not be enough; the matching data snapshot may also be required.

Moving to a pinned version would make Git history a more complete rollback record. Until then, I record the running image digest during incident work rather than assuming the tag tells the whole story.

Things worth remembering

Actual Budget is a useful reminder that criticality has two axes.

It has a small blast radius: when it stops, the cluster continues normally. It has valuable state: when its data disappears, replacing the pod is the easy part.

At the time of writing, the shape is:

Workload       single Deployment
Namespace      actualbudget
Ingress        Traefik, with optional Authelia
Storage        2 GiB local-path PVC
Deployment     Kustomize + Rancher Fleet
Image policy   latest tag
Recovery       restore data, reconcile, validate in the UI

It is deliberately a small Kubernetes application. The operational lesson is equally small: back up what cannot be rebuilt, and test the part the user actually cares about.