What the server holds
Actual is local-first. The FAQ says the primary database lives on each device; the sync server keeps a point-in-time copy of every budget plus the stream of changes, and serves the web app. The install page lists what needs the server: syncing between devices, bank sync, the API, and using Actual in a browser or on a phone. Budgets, reports and file import work without one.
| Location | Contents |
|---|---|
/data/server-files/account.sqlite | The hashed server password, the list of budget files and the active session token (config docs). Bank sync secrets go in the same account database, per secrets-service.js in the sync-server source. |
/data/user-files/ | Each budget file as a binary blob; encrypted blobs when end-to-end encryption is on. |
| Each device | A full local copy. The docs say it is not encrypted on the device and recommend full disk encryption. |
The docs publish no CPU or memory figure. They offer a latest-alpine tag for low-powered devices, and default upload limits of 20 MB per sync file and 50 MB per encrypted one, raised through ACTUAL_UPLOAD_* variables. Our reading, not the project's: the heavy lifting happens in the browser, so the server is a small Node process, and disk safety plus a way back in matter more than core count.
Sizing and choosing the machine
A whole machine means no hypervisor and no other customer under the container that holds your ledger, root access, and an OS you reinstall from the control panel. Our dedicated server lines run on our own network, AS19624, with nothing between you and the metal.
- Moonshot cartridge. The service page lists cartridges for one service per machine, which is this workload. No cartridge has a hardware RAID controller, so the off-machine copies below are the recovery plan. Only the m510 and m710x have a console; the rest have power control.
- Single-socket rack server. Every racked one has a full out-of-band console (iLO, iDRAC or a Supermicro BMC, depending on the make), the way back in if a firewall rule cuts SSH.
- Not the X-Gene ARM cartridge. It boots Ubuntu 16.04 and 18.04 and nothing else.
- Delivery and IPv6. A ready server is yours, handed over in about 30 minutes once payment is approved. IPv6 is added at checkout at no charge, so the firewall below covers both families.
Firewall, then the container on loopback
The stock compose file publishes 5006:5006. A port mapping with no address is published on every host address, 0.0.0.0 and [::]. Published traffic skips ufw's rules on IPv4, and on IPv6 too when Docker's own IPv6 networking is enabled; otherwise the [::] listener is docker-proxy, a process ufw does filter. So publish 5006 on 127.0.0.1 and turn ufw on, SSH only, before the first start. Check that docker version reports Engine 28 or newer: from 28 on, a port published on 127.0.0.1 answers on the machine itself only.
Prerequisite: Ubuntu, plus Docker (Engine with the docker compose plugin) set up per Docker's own install guide. The block below is our layout under /opt/actual, run as root. It keeps the official image, volume and restart policy, swaps the port line for loopback, and adds the Caddy service from the reverse proxy docs, which it does not start yet.
sed -i 's/^IPV6=.*/IPV6=yes/' /etc/default/ufw
ufw allow 22/tcp
ufw --force enable
docker version --format '{{.Server.Version}}'
install -d -m 700 /opt/actual && cd /opt/actual
mkdir -p actual-data
cat > docker-compose.yml <<'EOF'
services:
actual_server:
image: docker.io/actualbudget/actual-server:latest
ports:
- '127.0.0.1:5006:5006'
volumes:
- ./actual-data:/data
restart: unless-stopped
caddy:
image: caddy:alpine
restart: unless-stopped
ports:
- '80:80'
- '443:443'
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- ./caddy/data:/data
- ./caddy/config:/config
EOF
docker compose up -d actual_server
docker compose ps
The server password, set privately
A fresh server has no password. In the sync-server source (app-account.js, account-db.js), the bootstrap endpoint sets one for whoever calls it first and refuses once one exists. While port 5006 answers on loopback alone, only you can reach it.
- From your workstation, with your own SSH login:
ssh -N -L 5006:127.0.0.1:5006 you@server.example. - Open
http://localhost:5006. Through the tunnel the browser is on localhost, the case the HTTPS page says needs no certificate. - Set the server password and open or create a budget.
- Close the tunnel. Only now does Caddy start.
Lost it? The reset page's Docker form, run here as docker compose exec actual_server node /app/src/scripts/reset-password.js, prompts for a new password and keeps every file.
HTTPS and the reverse proxy
The reverse proxy page states that Actual needs a secure context and the COOP and COEP headers to enable SharedArrayBuffer for its SQLite engine. So a plain http:// address on the public IP will not do: the HTTPS page says HTTPS is needed to use all of Actual's features safely, with localhost the one exception. For internet access the docs' instruction is a reverse proxy with SSL in front; their Caddy example obtains and renews the certificate itself.
Point an A record (and AAAA, if you took IPv6) for your name at the server, put that name in place of budget.example.org, then run:
cd /opt/actual
cat > Caddyfile <<'EOF'
budget.example.org {
encode gzip zstd
reverse_proxy actual_server:5006
}
EOF
ufw allow 80,443/tcp
docker compose up -d
docker compose logs -f caddy
Caddy reaches the app over the compose network, so 5006 stays on loopback. The ufw allow line matters even though Caddy runs in Docker: on IPv6 without Docker's IPv6 networking, ufw filters the docker-proxy listener, and visitors plus certificate checks over AAAA would fail. A proxy on the host itself would need the same rule for both families. With nginx, the docs warn that duplicated COOP/COEP headers break the app and give proxy_hide_header lines to make nginx the only source. A GUI proxy instead: the Nginx Proxy Manager use case.
Bank sync and end-to-end encryption
Bank sync works only with the server and uses API credentials you get by signing up with a provider yourself. The docs list Akahu (New Zealand), Enable Banking (Europe), GoCardless Bank Account Data (Europe; the docs say it stopped taking new accounts in July 2025), SimpleFIN Bridge (North America) and Pluggy.ai (Brazil). Automatic syncing is off by default and runs only while Actual is open on a device.
End-to-end encryption is optional, per budget: open the file, Settings, Encryption, enable it. It adds a second password, which should differ from the server one, and the server can no longer read the budget. The docs are blunt: forget that password without a local copy and the data is gone, and it cannot be switched off except by exporting and re-importing. It does not cover bank sync credentials, which sit on the server, readable by whoever has its database.
Those credentials live in account.sqlite, so every copy of the data folder is a secret. Keep the backup directory at mode 700 and copy archives only to storage you control.
Backups, export and updates
Copy the entire data folder with the server stopped for the length of the copy, so the SQLite files are consistent. The script below is our own, not the project's; save it as /opt/actual/backup.sh with chmod 700. The trap restarts the container even when tar fails.
# /opt/actual/backup.sh -- our layout, run as root
set -euo pipefail
umask 077
out=/var/backups/actual
install -d -m 700 "$out"
cd /opt/actual
docker compose stop actual_server
trap 'docker compose start actual_server' EXIT
tar -czf "$out/actual-$(date +%F).tar.gz" actual-data docker-compose.yml Caddyfile
find "$out" -name 'actual-*.tar.gz' -mtime +14 -delete
- Schedule it as a file,
/etc/cron.d/actual-backup, holding one line:30 3 * * * root umask 077; install -d -m 700 /var/backups/actual; bash /opt/actual/backup.sh > /var/backups/actual/run-$(date +\%F).log 2>&1. - Copy the archives off the machine yourself, on a schedule.
- Restore an archive (it is checked before anything moves; the old folder is kept beside the new one): `f=/var/backups/actual/actual-2026-10-23.tar.gz; if tar -tzf "$f" actual-data >/dev/null; then cd /opt/actual && docker compose stop actual_server && mv actual-data "actual-data.before-$(date +%s)" && tar -xzf "$f" actual-data && docker compose start actual_server; else echo "archive missing or unreadable"; fi`.
- Also take the in-app export now and then: Settings, Data, Export data gives a zip of one budget, restored through Switch file, Import file, Actual. It is the docs' backup route, and the way out of encryption.
- Update with a fresh backup in hand:
cd /opt/actual && docker compose pull && docker compose up -d, the docs' two-step form. Stay onlatest; the docs tie a load error to moving fromnightlyback to a stable tag.
Licence and limits
Actual is under the MIT licence, per the repository's LICENSE.txt. Our general reading, not legal advice: you may use, copy, modify and distribute it, commercially too, provided the copyright and permission notice travel with every copy or substantial portion you distribute; running it for yourself triggers nothing, and it comes with no warranty.
Limits worth knowing: several user accounts need an OpenID provider; the docs advise against two people editing one budget at the same moment; the official mobile apps are deprecated, and the web app installs to a home screen instead; and automatic backups exist only in the desktop app, not the browser. For a different personal finance manager on this pattern, see the Firefly III use case; for passwords on the same pattern, Vaultwarden.
Questions
Why does the app fail at http://server-ip:5006?
The reverse proxy docs say Actual needs a secure context for the SharedArrayBuffer support its SQLite engine uses, and the HTTPS page names the two ways there: HTTPS, or access through localhost. A bare IP over plain HTTP is neither. Use the SSH tunnel for setup and a proxy with a certificate for daily use.
Do I lose my budgets if I reset the server password?
No. The reset page says the password can be reset without losing files or data. In this layout the command is docker compose exec actual_server node /app/src/scripts/reset-password.js, run in /opt/actual; it asks for the new password twice.
Does end-to-end encryption protect my bank connection?
No. It covers budget data only. Bank sync credentials are stored on the server outside the encryption, and anyone with access to the server database can read them, which is why the data folder and its backups need tight permissions.
Which image tag should I run?
latest points at the newest official release and is the one the docs recommend for most users; latest-alpine is the small Alpine build. nightly follows every commit to master and may carry more bugs, and the docs advise keeping backups if you try it.
Can I run the server without Docker?
Yes. The project publishes @actual-app/sync-server on npm, which needs Node.js 22 or newer and runs as actual-server, or you can build from source. The password, HTTPS and backup rules on this page apply the same way.