Contents

Actual Budget: Envelope Budgeting You Control

A local-first zero-sum budget with a sync server that knows nothing

Contents

The first of January is when people take an interest in their money, so here is the tool I use, and the reason I moved to it after YNAB raised its price to roughly a hundred dollars a year for the privilege of holding my transaction history on their servers.

The price was survivable. The specific insult was that YNAB4, the desktop version, worked perfectly, ran locally, synced through a folder in Dropbox, and got killed to make the subscription version viable. The software got worse and the price went from “once” to “forever”. Thousands of people kept running YNAB4 on unsupported Adobe AIR long past its expiry date, which tells you what they thought of the replacement.

Actual Budget is what happened next. James Long built a YNAB4-shaped budgeting app commercially, failed to make the business work, and in March 2022 open-sourced the whole thing under MIT. It has since been picked up by a genuinely active community. It does envelope budgeting properly, it runs local-first, and the sync server you host is a piece of infrastructure that can be, and in my case is, mathematically incapable of reading your finances.

Envelope budgeting, briefly, because the method is the point

Advertisement

Most finance apps are ledgers. They tell you what you spent, categorised, last month, in a pie chart. That is accounting, and it is retrospective, and it has never once stopped anyone buying a thing.

Envelope budgeting inverts the question. Every month you take the money you actually have — today, in the account, no forecasting — and assign all of it to a category. Rent, groceries, the car, the “annual insurance bill I forget every year” envelope that gets a twelfth each month. When you have assigned the last krone, the budget is done. The number to watch is “to be budgeted”, and its target is zero.

Then, when the groceries envelope is empty on the 24th, you have a decision. Move money from another envelope and feel where it came from, or stop. Both are fine. The point is that the trade-off is explicit and happens before the spend.

This is a 1930s technique with literal envelopes of cash, and every piece of software here is a re-implementation of it. Actual’s version is faithful, which is more than the current YNAB manages.

Local-first, and what that actually means

Here is the architectural decision that makes Actual worth self-hosting, and it took me a while to appreciate.

Your entire budget — the whole database — lives in the client. In the browser it is SQLite compiled to WebAssembly, persisted to IndexedDB; in the desktop app it is a real SQLite file. Every query, every calculation, every report runs on your machine against a local copy. This is why the interface is instantaneous in a way that web finance apps never are: there is no network in the loop.

The sync server does one job. It receives CRDT messages — small conflict-free updates like “category 7’s budgeted amount for 2025-01 became 4000” — stores them, and hands them to your other devices. It never computes your budget. If it vanishes tomorrow, every device that has the file keeps working, permanently, offline.

Turn on end-to-end encryption and the server’s ignorance becomes total: the messages are encrypted with a key derived from a password the server never sees. It is holding opaque blobs. That is a genuinely comfortable position for a box that faces the internet, and it is the reason I am relaxed about exposing this service in a way I would not be about a Firefly III instance holding my full transaction history in a database the app can read.

The trade is real and you should hear it now. If you enable E2E encryption and lose that password, your data is gone. Nobody can recover it. There is no reset link. I keep mine in a password manager and in a sealed envelope in a drawer, which sounds paranoid until you consider the alternative is a permanently unreadable financial history.

The setup

Advertisement

Actual is refreshingly small. One container, no database service, no Redis.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
services:
  actual:
    image: actualbudget/actual-server:24.12.0
    container_name: actual
    environment:
      - ACTUAL_PORT=5006
      - ACTUAL_TRUSTED_PROXIES=192.168.1.0/24
      - ACTUAL_UPLOAD_FILE_SYNC_SIZE_LIMIT_MB=100
      - TZ=Europe/Copenhagen
    volumes:
      - /srv/appdata/actual/data:/data
    ports:
      - "127.0.0.1:5006:5006"
    healthcheck:
      test: ["CMD-SHELL", "node src/scripts/health-check.js"]
      interval: 60s
      timeout: 10s
      retries: 3
    restart: unless-stopped

That is the whole thing. Under /data you get server-files/ (the budget blobs), user-files/ and a couple of small SQLite databases for accounts and sessions. Total size for four years of my transactions: 41MB. It runs in about 90MB of RAM. After the wger stack I looked at over Christmas, this feels like a rounding error.

Behind a proxy, as usual:

1
2
3
budget.mylab.local {
    reverse_proxy 127.0.0.1:5006
}

First visit asks you to set a server password. That password gates access to the server; the E2E encryption password (set later, per budget file, under Settings → Encryption) is a separate secret and should be a different string. Conflating them is a mistake I have watched people make and then regret at restore time.

Then create a budget, or import one. The importers cover YNAB4, the current YNAB, and the actual-budget export format, and they work well. My YNAB4 file from 2019 came across with every category and every historical month intact, which frankly astonished me.

Bank sync, and whether to bother

Actual can pull transactions automatically through two providers. In Europe that is GoCardless Bank Account Data, the open-banking API formerly called Nordigen, which has a free tier for personal use. In North America it is SimpleFIN Bridge, which costs a nominal amount per year.

The GoCardless path: register for developer access, generate a secret ID and key, put them in Actual’s settings, then link each account through your bank’s own OAuth consent screen. From then on, transactions arrive on demand.

1
2
3
4
    environment:
      - ACTUAL_OPENID_DISCOVERY_URL=
      # GoCardless credentials are entered in the UI, not here --
      # they end up in /data, so /data is a secret-bearing volume.

That comment matters. Once you configure bank sync, your /data directory contains credentials that can read your bank statements. Treat the volume accordingly: encrypted at rest if the host allows it, and never in a git repo, which is the whole reason tools like the ones in Secrets Management with SOPS and age exist.

Two caveats from a year of using it. The consent tokens expire — 90 days under PSD2 rules — so roughly quarterly you will re-authorise each account through a bank’s mobile app, and every bank’s flow is a small unique horror. And the transaction descriptions banks hand over are frequently useless: KORTKØB 4711 tells you nothing about which shop.

Which brings us to the feature that redeems it.

Rules, the actual killer feature

Actual’s rules engine runs on import and rewrites transactions. Match on payee, amount, notes, account, imported description; then set payee, category, notes, or split the transaction.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
IF   imported payee contains "REMA 1000"
THEN set payee to "Rema 1000"
     set category to "Groceries"

IF   payee is "Landlord" AND amount is 9500.00
THEN set category to "Rent"

IF   imported payee contains "DSB"
THEN set payee to "DSB"
     set category to "Transport"

Fifteen rules cover about ninety per cent of my transactions. Import becomes: click sync, glance at eight uncategorised items, done in ninety seconds. That ninety seconds is the difference between a budget that gets maintained and one that gets abandoned in February, and the whole system lives or dies on it.

Build the rules incrementally. Every time you categorise something by hand, ask whether it will recur, and if so make a rule from it there and then. After two months the hand-categorising nearly stops.

Schedules, and the bills that ambush you

Advertisement

The feature that converted me from “this is a decent YNAB4 clone” to “I am keeping this” is schedules, and it is worth a section because it is where envelope budgeting stops being bookkeeping and starts being useful.

A schedule is a transaction you know is coming: rent on the first, the phone bill on the 12th, the insurance premium every March. You declare the payee, the account, the amount (fixed or approximate) and the recurrence, and Actual then does two things. It shows the upcoming payment in the account register before it happens, greyed out, so the balance you see accounts for money already spoken for. And it can match the real transaction to the schedule on import, so the prediction resolves into a fact with no intervention.

The second-order effect is the one that matters. Once every known outgoing is a schedule, the “to be budgeted” number stops lying to you. The 4,000kr sitting in your current account on the 3rd is visibly 4,000kr minus a rent payment and a standing order, and the temptation to treat it as spendable evaporates because the software has already spent it in front of you.

Annual bills are where this earns its keep. The insurance premium that arrives every March has ruined more budgets than any impulse purchase, because it is large, it is forgettable, and it lands in a month that had no plan for it. Actual’s answer is a schedule plus a category with a monthly goal:

1
2
3
Category:  Insurance
Goal:      Save 800.00 by 2025-03-01, repeating yearly
Schedule:  "Insurance Co" — approx 9600.00 — every year on the 1st of March

The budget screen then tells you every month how much that category needs, the money accumulates quietly, and March is uneventful. This is the single mechanic that separates people who budget from people who have a spreadsheet, and it is a decade-old idea implemented cleanly.

Rules and schedules interact well: a rule that recognises the payee lets the schedule match automatically, which means the January import categorised the insurance premium correctly without me being present. That is the level of automation I want from money software — invisible until a decision is genuinely required.

Troubleshooting

“Failed to sync” with the server clearly running. Nine times in ten this is the proxy. Actual syncs over HTTP with reasonably large payloads and needs the standard forwarded headers; if you are running nginx, confirm X-Forwarded-Proto is set and that your body-size limit is above the sync limit. Check the server is reachable and answering:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
$ curl -s https://budget.mylab.local/info | jq .
{
  "status": "ok",
  "data": {
    "build": {
      "name": "@actual-app/sync-server",
      "version": "24.12.0"
    }
  }
}

The browser has a stale copy and the desktop app has a newer one. Local-first means multiple truths can exist. Actual resolves through CRDTs and usually converges, and when it does not, the reliable fix is to close the file on the confused client and re-download it from the server, which discards local state in favour of the sync log. Know which client is authoritative before you press anything.

Everything vanished after clearing browser data. In the browser, the budget lives in IndexedDB. “Clear site data” deletes it. If it had synced, re-downloading from the server restores it; if you had a local-only file, it is gone. This is why I run the desktop app on one machine — a real file on a real disk is a thing my backup system understands.

E2E encryption enabled and now a device will not open the file. Each device needs the encryption password entered once. There is no recovery. See earlier, re: drawer.

Upload rejected for a large budget. ACTUAL_UPLOAD_FILE_SYNC_SIZE_LIMIT_MB defaults low enough to bite a long history with attachments. Raise it, and raise the proxy’s limit to match.

Backups

The sync server is a relay, and a relay is a poor archive. Actual knows this and keeps automatic local backups in the client, which is fine for undoing your own mistake and no help at all when the disk dies.

The export is a zip containing the SQLite database, and it is the artefact worth keeping:

Settings → Export data produces <budget>-2025-01-01.zip. Do it monthly, put it in the same pipeline as everything else — mine is the restic setup from Restic + Rclone: Offsite Backups That Survive Your Own Mistakes — and confirm once, by importing an export into a fresh instance, that the file is what you think it is.

Also back up /srv/appdata/actual/data, with the SQLite caveat that has come up in every one of these posts and applies to Actual’s small databases as much as to anything else:

1
2
3
$ docker stop actual
$ restic backup /srv/appdata/actual/data
$ docker start actual

A three-second outage on a personal budget server is not an incident. Stopping the container is the simplest correct answer, and SQLite’s tolerance for exactly this kind of crude, effective operation is part of why I keep arguing for it in SQLite Is a Production Database (For Your Homelab, Anyway).

The verdict

Actual is the closest thing to YNAB4 that exists, it is MIT-licensed, the community is real, releases arrive monthly, and the local-first architecture means the app is fast and the server is dumb. Ninety megabytes of RAM, one container, no database to tune. It is one of the tidiest self-hosted applications I run.

The reporting is thinner than the commercial competition, the mobile experience is a PWA and feels like one, and bank sync in Europe involves re-authorising through your bank every ninety days for as long as you use it.

Run it if envelope budgeting is a method you already believe in and you want the numbers on your own disk. It is the best implementation available at any price.

Skip it if you want a ledger that categorises the past and produces charts. That is a different tool with a different philosophy — I compared the two schools in Firefly III: Self-Hosted Personal Finance — and using Actual for it will annoy you both.

Three years in, my budget still opens instantly, my server has never seen a transaction of mine in the clear, and I have paid nobody a subscription. On the first of January, that is the state I want to be in.

Advertisement
Advertisement
Smarc
Written by Smarc

Founder and editor of vo.rs. A lifelong tinkerer who self-hosts far more than is sensible, hardens Linux boxes for fun, and prods the latest AI tools to see what they can really do. The how-to guides here are the notes Smarc wishes had existed the first time round.