Actual Budget
Actual Budget asks one question at the start of every month: what is this money for? You hand out what is actually in your accounts across categories - rent, food, the car, the trip you keep postponing - and everything you spend afterwards is measured against the amount you set aside. Overspend somewhere and you decide what it comes out of, so the budget always adds up to money you really have. All of it runs on your own server: your salary, your balances and your spending stay there, and no bank is connected unless you set that up yourself.
Install it from Suite Manager → Apps — once running it lives at
actual.<your-domain> and on your Home dashboard.
What you can do
- Give every amount a job — Budget from the money already in your accounts rather than a forecast. Each category holds what you assigned it this month, so 'can I afford this' has an answer instead of a guess.
- The bills you already know about — Enter rent, insurance and subscriptions once as schedules. Actual shows what is due before the end of the month and keeps that money out of the total you think is free.
- Reports that answer real questions — Net worth over time, cash flow month by month, and where the spending actually went - built from your own history, not a category average from someone else's data.
- Your money stays your business — The budget file lives on your server and works offline in the browser. Bank connections are optional and off until you add credentials yourself, and sync between devices can be end-to-end encrypted.
A self-hosted YNAB alternative
Actual Budget does the job people use YNAB, Mint, Quicken, Monarch Money, EveryDollar, Goodbudget, PocketGuard, and Copilot Money for, on a server you own: the same everyday task, without a subscription and without an account at a company that also keeps your data. If you arrived comparing one of these, this is the app My Own Suite installs in its place — its privacy posture below says exactly what it does and does not send anywhere.
- YNAB alternative
- Mint alternative
- Quicken alternative
- Monarch Money alternative
- EveryDollar alternative
- Goodbudget alternative
- PocketGuard alternative
- Copilot Money alternative
Good to know
Actual Budget
Your choice Privacy grade B (A is best, D is worst)Something leaves your server in normal use, and the app has a setting that stops it. MOS left the choice to you because it is a real trade-off. The full assessment says what leaves, who receives it, and where the switch is.
- Resource needs: Light resources — A single small web service storing everything in one data folder. It idles quietly between visits.
- Package-provided privacy notes (not independently verified by MOS): Actual Budget keeps your accounts, transactions and budgets in a file on your own server. The image carries no analytics or crash reporting, and no bank is contacted unless you set up a bank connection yourself.
- Bank sync is optional and off by default. GoCardless, SimpleFIN, Enable Banking, Pluggy.ai and Akahu each need API credentials you sign up for and enter yourself; with none entered, nothing reaches out to a bank.
- One request does leave by default, and it is the browser that makes it, not the server: Actual asks GitHub for the number of its latest release so it can tell you when a newer version exists. It sends nothing about you or your money, and 'Display a notification when updates are available' in Settings switches it off.
- Sync between your devices can be end-to-end encrypted with a password only you hold. Bank-sync API keys, if you add any, are stored on the server outside that encryption.
- Budget files, the account database and sessions live in one package-owned MOS volume. Back it up before this becomes the only record of your finances.
Privacy assessment
This is the assessment behind Actual Budget's posture grade, exactly as it ships inside the package. It describes package version 0.1.0 and nothing else — install a different version and its own assessment travels with it. Reviewed 12 August 2026, due for reassessment by 12 February 2027.
What was reviewed
- Package — version 0.1.0, content digest
d384cff9d811…at commitc48bb6242a77 - Actual Budget — version 26.8.1 from
ghcr.io/actualbudget/actualpinned at6478d9ddfc09…
Deliberately outside this assessment:
- Actual's official desktop application, which MOS does not ship; this package serves the web client bundled in the sync-server image
- Third-party clients built on @actual-app/api
- Bank aggregation providers an owner signs up with and configures after installation
Evidence
Observed Watched on the wire, in a running instance of this package.
-
The server contacts nothing. Across two captures totalling 27 minutes of wall clock - 832.3 seconds over first boot, bootstrap and browsing, then 778.2 seconds covering budget creation, adding a local account, entering transactions, editing categories and syncing to the server - a logging dnsmasq was the container’s only resolver and tcpdump ran inside its own network namespace, so every packet crossing the package boundary was recorded. The only hostname the container ever resolved was the injected control example.com, and the only TLS server name it ever sent was example.com. Both external IP conversations in each pass, 104.20.23.154 and 172.66.147.243, are that control’s own addresses as answered by the resolver, and both fall inside the injection windows. Container-to-external payload was 4,622 bytes in the first pass and 4,874 in the second, all of it control.
Source: .local-tools/privacy-rigs/actual-budget capture-20260812-172931: actual.pcap, actual-pass2.pcap, dns-queries.log, controls.txt · checked 2026-08-12
-
The injected control appeared in both channels in both passes, so the silence is a measurement rather than a capture that was not working. The control was issued from inside the container itself, resolved through the logging resolver, and shows in the DNS log, the TLS server names and the packet capture each time.
Source: controls.txt and controls-verify.log for both passes of capture-20260812-172931 · checked 2026-08-12
-
A human reviewer’s authenticated browser session was recorded in full and confirms the release check as the single external request. The HAR holds 114 entries over 2 minutes 36 seconds of clicking through every screen the reviewer could find: 112 went to the app on localhost across 80 distinct endpoints, one was an inline data: URI that touches no network, and exactly one left the machine - GET https://api.github.com/repos/actualbudget/actual/releases/latest, answered 200 in 244 ms with a 4,213-byte body. It carried no cookie, no request body and no identifier of any kind, and no response set a cookie. Fonts and all other assets were served locally.
Source: reviewer-session.har in capture-20260812-172931, summarised by host and by request · checked 2026-08-12
-
The release check is issued by the browser and discloses the address the app is reached at. Its Referer and Origin headers carry the deployment URL, which in this capture was http://localhost:18881 because the reviewer session ran over loopback; on a real MOS install those headers would carry the app’s public hostname, so GitHub would observe the owner’s browser IP alongside the hostname of their Actual Budget. Nothing else is disclosed: no version, no install identifier, no usage data.
Source: request headers of the api.github.com entry in reviewer-session.har · checked 2026-08-12
-
The bank-sync status probes stay local and produce no egress. The reviewer session posted twice each to /gocardless/status, /simplefin/status and /pluggyai/status while opening the bank-connection screens; every one went to the app on localhost, and the packet capture recorded no outbound connection from the container in the same window. With no provider credentials entered there is no code path that reaches a bank.
Source: reviewer-session.har local endpoint list, cross-checked against actual-pass2.pcap over the same window · checked 2026-08-12
Configured Read from the configuration MOS actually ships.
-
MOS sets no environment variables for this package at all. The rendered runtime projection is one service built from the package Dockerfile, one named volume at /data, one published route, and an empty environment block. Every upstream default therefore applies unchanged, which is stated here so that no claim elsewhere in this review can rest on a MOS setting that does not exist.
Source: apps/actual-budget/manifest.json and the projection rendered by renderInstanceProjections for this package · checked 2026-08-12
Documented Taken from upstream documentation, policies or source.
-
The pinned image contains no analytics, crash-reporting or usage-telemetry library. Searching the compiled server bundle and the compiled web client for sentry, posthog, analytics, matomo, plausible and google-analytics returns no match beyond a case-insensitive collision with an unrelated identifier. There is no telemetry subsystem to disable, which is a different and stronger statement than a telemetry feature shipped switched off.
Source: grep of /app and /app/node_modules/@actual-app/web/build in the pinned image (sha256:6478d9ddfc0924479c09e6699c205e354c6f2216dfe7de3c0fb7b590d6edcdc5) · checked 2026-08-12
-
The server's complete set of compiled-in outbound hosts is eight, and every one is reached only after an owner switches something on. Five are bank aggregators - bankaccountdata.gocardless.com, bridge.simplefin.org, api.enablebanking.com, and the Pluggy.ai and Akahu endpoints - each of which requires API credentials the owner obtains from that provider and enters; with none entered there is no code path that contacts a bank. Three are GitHub - raw.githubusercontent.com, api.github.com and github.com - and belong to the experimental plugin store. Three further hostnames appear in the bundle (expressjs.com, cheatsheetseries.owasp.org, danskeci.com) and are documentation URLs inside source comments retained by the bundler, never fetched.
Source: extraction of every https:// literal from /app/app.js, /app/chunks and /app/src in the pinned image, then reading each in context · checked 2026-08-12
-
The plugin store is the server's only non-bank egress and it is gated four ways. Its entry point is the /cors-proxy route, which requires a valid session, is rate limited to 25 requests per minute, resolves the target hostname through DNS and refuses any address in a private, loopback, link-local, reserved, broadcast or unspecified range, and forwards only to repositories on an allow-list fetched from actualbudget/plugin-store. Nothing is fetched until the browser asks the proxy for it; the server performs no plugin request at start-up or on a schedule.
Source: src/app-cors-proxy.js and src/util/ssrf.ts as compiled into /app/chunks/app-*.js at 26.8.1 · checked 2026-08-12
-
The web client asks GitHub for the project's latest release number by default. getLatestVersion() fetches https://api.github.com/repos/actualbudget/actual/releases/latest and compares the tag with the running client's version so the About screen can report that a newer release exists. It is a read of a public URL that carries no install identifier, no version and no usage data, and it is issued by the owner's browser rather than by the server, so the address disclosed to GitHub is the owner's. It is gated on the global preference notifyWhenUpdateIsAvailable, which defaults to on and is exposed as the Settings checkbox 'Display a notification when updates are available'. Because it is a client preference and not a server setting, this package has no environment variable with which to pin it, and MOS does not disable it.
Source: getLatestVersion and the getLatestAppVersion thunk in the compiled web client at 26.8.1, and the About settings component that owns the checkbox · checked 2026-08-12
-
Normal use requires no upstream account and no external service. The server ships with no credentials and no registration: the first visitor to the app is offered a set-a-password screen, that password is stored as an argon2id hash in the account database inside the package volume, and there is no licence check, activation or account system anywhere in the product. Budget files, the account database and sessions all live under /data in the package-owned MOS volume.
Source: the /account/bootstrap and /account/needs-bootstrap handlers at 26.8.1, and the volume layout observed in the pinned image · checked 2026-08-12
-
Sync between an owner's devices can be end-to-end encrypted with a key derived from a password the server never receives, in which case the server stores only ciphertext for the budget file. Bank-sync API secrets, if an owner adds any, are explicitly outside that encryption and are stored server-side; upstream documents this and recommends self-hosting because of it.
Source: Actual Budget end-to-end encryption and bank sync documentation · checked 2026-08-12
-
The project publishes no terms of service and no privacy policy, and the absence is consistent rather than an omission: Actual Budget operates no hosted service, no account system and no back end of its own, so there is no data-controller relationship for a policy to describe. The MIT licence is the only instrument binding an operator. The publisher is the actualbudget GitHub organisation, a community project with no corporate owner and no ownership change to report; the product began as a commercial application and was open-sourced by its original author in 2022, which is the last ownership change on record.
Source: repository contents and licence at v26.8.1, and the actualbudget GitHub organisation record · checked 2026-08-12
What this review does not settle
Published rather than smoothed over. Each of these is a limit of the method, not a finding against the app.
- The reviewer session ran over loopback, so the Referer and Origin the release check sends were http://localhost:18881. A deployment reached through a public domain would send that domain to GitHub instead; this is a reasoned extension of what was measured, not itself measured.
- The plugin store was never exercised. /cors-proxy is the server’s only non-bank egress path, it is gated behind a session, a rate limit, an SSRF check and a repository allow-list, and nothing in either capture invoked it - but that means its behaviour is read from source rather than observed.
- No HAR was retained for the second pass, so the client-side claims rest on the first reviewer session. The second pass is covered at the packet level only.
- Whether the update-notification preference is stored per browser or on the server has not been established; the code path writes it through the client’s own key-value store, but the sync server also gained a server_prefs table in a recent release. An owner who turns it off may have to do so again in another browser.
- TLS payloads were not decrypted, so conclusions bound behaviour by destination, method, header content and volume rather than by asserting the contents of encrypted bodies.
- The captures span 27 minutes and cover first boot, bootstrap, budget creation and normal use. A scheduled task with a period longer than that would not have been seen.
Policies read
- license — actualbudget organisation · retrieved 2026-08-12
How this review was produced
- Method — AI-assisted review, checked by Rasmus Puls
- Model — Claude Opus 5 (Anthropic)
- Workflow —
assess-app-privacy at
5146372c3c87 - Repository commit —
c48bb6242a77
Technical reference
Maintained alongside the package at apps/actual-budget/README.md and rendered here
as-is.
Actual Budget MOS Package
Services
actual: the Actual sync server, which also serves the web client. Single service, exposed through the publicactual.<base-domain>app route.
There is no database or cache service. The sync server keeps its account database, sessions and budget files as SQLite files under one data directory.
Environment Variables
The package sets none. Every upstream default is correct for the way MOS runs the container, and the values worth knowing are:
ACTUAL_PORT(default5006): matchesinternalPort.ACTUAL_DATA_DIR(default/data): parent of everything persistent; the volume is mounted here.ACTUAL_SERVER_FILES(default/data/server-files),ACTUAL_USER_FILES(default/data/user-files): both inside the mounted volume, so neither needs overriding.ACTUAL_TRUSTED_PROXIES(default: private and loopback ranges): Caddy reaches the container over the package-owned Docker network from a private address, so the default already covers it.ACTUAL_LOGIN_METHOD(defaultpassword): the server prompts for a password on first open.openidandheaderare upstream options this package does not configure.ACTUAL_UPLOAD_FILE_SYNC_SIZE_LIMIT_MB(default 20),ACTUAL_UPLOAD_SYNC_ENCRYPTED_FILE_SYNC_SIZE_LIMIT_MB(default 50): sync payload ceilings. A budget with many years of transactions can grow past 20 MB; raising the limit is the fix if sync starts failing on upload.
The server reads /data/config.json if present, and environment variables override it. MOS does not write that file.
First Start And The Bootstrap Window
The image ships no credentials. Until someone calls POST /account/bootstrap, GET /account/needs-bootstrap reports bootstrapped: false and the first visitor to the app URL is offered the “set a password” screen with no authentication in front of it.
The route is public as soon as the install finishes, and app hostnames are visible in certificate transparency logs, so this is a real if short window. Open actual.<base-domain> and set the password immediately after install.
The package deliberately collects no password as a setup field: the server has no environment variable that presets one, and the only startup-time bootstrap path upstream supports is OpenID. Closing the window properly needs an upstream credential-seeding mechanism, not manifest fields.
Volumes And Persistence
data:/data: everything. It holdsserver-files/account.sqlite(the server password hash, sessions, and any bank-sync API keys),user-files/(one folder per budget file, each with its own SQLite database and sync journal), and.migrate.
This single volume is the backup target; losing it loses the budgets. Disable stops and removes the container while keeping the route, volume and any stored secret references. Uninstall removes the container, route, MOS-owned Homepage shortcut, this volume, config and secret references.
Actual is local-first: each browser holds its own copy of the budget and syncs changes to the server. A restored server volume is authoritative, but a client that still has newer local changes will try to sync them on next open.
Health Check
http://actual:5006/health
Returns 200 with {"status":"UP"} and needs no session, before and after bootstrap. It is a dedicated liveness endpoint rather than a page borrowed from the UI, so it stays meaningful if the login flow changes.
Outbound Network Behaviour
The image contains no analytics or crash-reporting library.
The server makes no outbound request on its own. The upstream hosts compiled into it are reached only when an owner switches something on:
bankaccountdata.gocardless.com,bridge.simplefin.org,api.enablebanking.com, and the Pluggy.ai and Akahu endpoints: bank sync, each requiring API credentials the owner obtains and enters.raw.githubusercontent.com,api.github.com,github.com: the experimental plugin store, fetched through the server’s/cors-proxyroute. That route requires a valid session, is rate limited to 25 requests per minute, resolves and rejects private, loopback and link-local targets, and only forwards to repositories on the upstream plugin allowlist. Nothing is fetched until the browser asks for it.
The web client does make one request by default. getLatestVersion() fetches https://api.github.com/repos/actualbudget/actual/releases/latest and compares the tag with the running client’s version, so the About screen can say a newer release exists. Points worth being precise about:
- It comes from the owner’s browser, not from the server, so the address disclosed to GitHub is the owner’s, not the box’s.
- It carries no identifier: no install id, no version, no usage data. It is a read of a public URL.
- It is gated on the global preference
notifyWhenUpdateIsAvailable, which defaults to on and is exposed as “Display a notification when updates are available” in Settings. - Observed firing on first load of the budget during the screenshot capture session, which is how it was found rather than inferred.
Because it is a client-side preference rather than a server setting, there is no environment variable for this package to pin, and MOS does not disable it.
Upgrades
The server runs SQLite migrations on start for both the account database and each budget file, hence backupRequired: true and brief downtime.
Actual’s own guidance is to keep the sync server and the client in step; because MOS serves the client from the same image, that happens by construction. A budget file migrated by a newer server is not guaranteed to open on an older one, which is why rollback: safe refers to the container, not to reversing a data migration.
Import And Export
Actual imports YNAB4 and nYNAB budgets and reads CSV, OFX/QFX and CAMT.053 transaction files, all through the web interface. Export produces a .zip of the budget file that can be re-imported into any Actual instance; it is the portable copy to keep alongside MOS volume backups.