How to Self-Host Actual Budget with Docker
Actual Budget is a local-first budgeting application with an optional sync server. The Docker Compose deployment is small: one server container and one persistent /data directory. No PostgreSQL, no Redis. The real work is elsewhere. You have to claim the uninitialized server before it reaches the Internet, keep the browser security headers intact, understand what the optional encryption does not cover, and prove that backups and client sync work.
This guide deploys Actual Budget 26.7.0, the current release when we checked on July 18, 2026, from the official Docker Hub image on a Debian or Ubuntu VPS. Docker publishes port 5006 only on loopback. You set the server password through an SSH tunnel before Caddy exposes budget.example.com, then test a real budget from two clients.
Prerequisites
You need:
- a Debian or Ubuntu VPS with root or sudo access;
- an A record for
budget.example.compointing to the VPS, plus an AAAA record only when IPv6 is configured and filtered correctly; - TCP 80 and 443 open for Caddy, along with your administrative SSH port;
- a modern browser on two devices, or two isolated browser profiles;
- encrypted storage outside the VPS for budget exports and server backups.
First install Docker Engine and the Compose plugin. Docker’s convenience script is the fastest route; if running a downloaded script as root is against your policy, your distribution’s packages work too. Then add Caddy and UFW:
curl -fsSL https://get.docker.com | sudo bash
sudo docker compose version
sudo apt update
sudo apt install -y caddy ufw
Allow SSH before enabling the firewall. Replace port 22 if the host uses another administrative SSH port; our guide to securing SSH on a VPS covers moving the port and hardening logins without locking yourself out:
sudo ufw default deny incoming
sudo ufw default allow outgoing
sudo ufw allow 22/tcp
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw enable
sudo ufw status verbose
Mirror those inbound rules in any provider firewall. Do not open port 5006 publicly.
Create the Actual Budget Compose project
The official Docker configuration runs one actual-server service, mounts /data, and uses node scripts/health-check.js as its health check. The file below keeps that health check and adds a pinned version, a loopback-only host port, and log rotation.
As of the 26.7.0 image (checked July 18, 2026), the image creates an actual account with UID/GID 1001, but its OCI configuration does not select that account, so the container runs as root by default; a later image may change this, so recheck after upgrades. Create a root-owned data directory to match. Do not add user: "1001:1001" until you have proved that startup, writes, upgrades, backup, and restore all work under that override.
sudo install -d -m 750 /opt/actual
sudo install -d -m 750 /opt/actual/actual-data
cd /opt/actual
Create /opt/actual/compose.yaml:
sudo tee compose.yaml > /dev/null <<'YAML'
services:
actual_server:
image: docker.io/actualbudget/actual-server:26.7.0
container_name: actual-server
restart: unless-stopped
ports:
- "127.0.0.1:5006:5006"
volumes:
- ./actual-data:/data
healthcheck:
test: ["CMD-SHELL", "node scripts/health-check.js"]
interval: 60s
timeout: 10s
retries: 3
start_period: 20s
logging:
options:
max-size: "10m"
max-file: "3"
YAML
/data/server-files/account.sqlite stores the hashed server password, sessions, and the server’s budget-file registry. Budget blobs live under /data/user-files. The server configuration reference documents those paths and the supported environment variables. Keeping the defaults puts the whole server state under one mount.
Start it and check the listener:
cd /opt/actual
sudo docker compose up -d --wait --wait-timeout 120
curl -fsS http://127.0.0.1:5006/health
sudo ss -lntp | grep ':5006'
The health endpoint should return {"status":"UP"}, and the listener must show 127.0.0.1:5006, not 0.0.0.0:5006 or [::]:5006.
Initialize the password through an SSH tunnel
An uninitialized Actual server asks the first visitor to set its server password. Do that privately before adding a public Caddy route.
From your workstation, open a tunnel and leave it running:
ssh -N -L 5006:127.0.0.1:5006 YOUR_USER@SERVER_IP
Browse to http://127.0.0.1:5006. Set a long, unique server password and store it in a password manager. This is the login password for the server, not an end-to-end encryption password.
Sign out, reload the page, and sign in again. Also try a wrong password. If the original setup screen reappears or the wrong password works, check the actual-data ownership and container logs before continuing.
The official password reset procedure runs a script inside the container, which means anyone with root or Docker access can reset the server authentication boundary. Limit administrative access to the VPS accordingly.
Add Caddy without replacing Actual’s security headers
Actual needs HTTPS and cross-origin isolation for the SharedArrayBuffer used by its browser-side SQLite engine. The server sets these response headers itself:
Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corp
The official reverse-proxy guide warns that duplicate COOP or COEP headers can cause a fatal SharedArrayBufferMissing error. Caddy passes the upstream headers through, so use a plain reverse proxy and do not add another copy.
Create the site after the private password test has passed. Keep site snippets in a separate imported file so adding Actual does not erase other Caddy hosts; the conditional import appends the top-level import only when the main file lacks it:
sudo install -d -m 755 /etc/caddy/sites-enabled
sudo tee /etc/caddy/sites-enabled/actual.caddy > /dev/null <<'CADDY'
budget.example.com {
encode zstd gzip
reverse_proxy 127.0.0.1:5006
}
CADDY
if ! sudo grep -Eq \
'^[[:space:]]*import[[:space:]]+/etc/caddy/sites-enabled/\*' \
/etc/caddy/Caddyfile; then
printf '\nimport /etc/caddy/sites-enabled/*\n' | \
sudo tee -a /etc/caddy/Caddyfile > /dev/null
fi
sudo caddy validate --config /etc/caddy/Caddyfile
sudo systemctl reload caddy
Caddy obtains and renews the certificate once DNS and both firewalls allow TCP 80/443. Check the public path and the headers:
curl -fsS https://budget.example.com/health
curl -fsSI https://budget.example.com/ | grep -i '^cross-origin'
The header check should print exactly one Cross-Origin-Opener-Policy: same-origin and one Cross-Origin-Embedder-Policy: require-corp. Open the browser console and confirm there is no SharedArrayBufferMissing error.
Prove budget creation and two-client sync
A healthy endpoint proves that Node can answer a request. It does not prove that a budget can be written, downloaded, decrypted, or synchronized.
In the first browser:
- Sign in at
https://budget.example.com. - Create a budget named
VPS restore test. - Add an account named
Checking testwith an opening balance of 1,000. - Add a transaction for 12.34 with payee
Compose testand a note containing today’s date. - Wait for the sync indicator to settle, then reload and confirm the balance and transaction remain.
Open a private window, a separate browser profile, or a second device with no Actual local storage. Sign in to the same server, select the cloud budget, and let it download. Confirm the account, exact transaction amount, payee, note, and resulting balance.
Make a second change from that client, such as changing the note to Second client sync works. Wait for sync, return to the first client, and confirm the edit arrives. Avoid simultaneous edits to the same field during this acceptance test. Actual supports concurrent clients, but a deterministic test is easier to diagnose.
Decide whether to enable budget encryption
Actual’s sync documentation describes optional end-to-end encryption per budget. Enabling it creates a second password that should differ from the server login password. The client encrypts budget data before upload, and each new device needs that second password once to derive the key.
Four boundaries matter:
- Encryption is optional and applies per budget, not automatically to every file on the server.
- It cannot be switched off in place. Returning to an unencrypted file requires an export and import.
- If every local copy is gone and the encryption password is forgotten, the budget cannot be recovered. A device that still has a local copy may reset sync and create a new key.
- Local browser or desktop data remains unencrypted. Use full-disk encryption on every client device.
If you enable encryption, open the budget settings, choose Enable encryption, save the second password separately, and repeat the clean second-client download. The second client must ask for the encryption password and then show the same transaction.
Bank sync has a different trust boundary. Actual’s bank-sync documentation states that API keys, secrets, and bank-sync tokens are stored on the server, outside budget E2EE. A server administrator, hosting provider, or attacker with direct database access can read them. Keep those credentials out of Compose files, shell history, screenshots, and tickets, and encrypt backups that may contain them. Actual also requires the user to trigger bank sync; this guide does not assume an unattended schedule.
Export each budget and back up all of /data
Actual provides a portable application backup. Open each budget, go to its settings, choose Export Data, and save the Actual ZIP. The official backup and restore guides use that ZIP for import into a new budget. Export every budget separately and keep the old budget until the imported copy has been checked.
The ZIP is not the complete server. A stopped archive of /data also covers authentication, sessions, the server’s file registry, all budget blobs, and server-side bank-sync state. Take both.
Create a consistent archive with Actual stopped:
cd /opt/actual
sudo install -d -m 700 /var/backups/actual
archive="/var/backups/actual/actual-$(date +%F-%H%M%S).tar.gz"
sudo docker compose stop actual_server
sudo tar --numeric-owner --xattrs --acls -C /opt/actual \
-czf "$archive" actual-data compose.yaml
sudo docker compose start actual_server
sudo chmod 600 "$archive"
sudo sha256sum "$archive" | sudo tee "$archive.sha256" > /dev/null
sudo chmod 600 "$archive.sha256"
curl -fsS --retry 12 --retry-delay 5 --retry-all-errors \
http://127.0.0.1:5006/health
Encrypt the archive and the ZIP exports, then copy them off the VPS. A backup that exists only on the same disk is not a disaster-recovery copy.
Restore the same version in isolation
Test the archive without touching production. The drill below verifies the checksum, extracts into a fresh directory, and runs a separate Compose project on loopback port 15006 with an internal Docker network that has no external route. That last control keeps the restored server from contacting real bank-sync providers.
BACKUP=$(ls -1 /var/backups/actual/actual-*.tar.gz | sort | tail -n 1)
sudo sha256sum -c "$BACKUP.sha256"
restore_dir="/opt/actual-restore-$(date +%Y%m%d%H%M%S)"
sudo install -d -m 700 "$restore_dir"
sudo tar --numeric-owner --xattrs --acls -C "$restore_dir" -xzf "$BACKUP"
cd "$restore_dir"
Create compose.restore.yaml in that directory:
services:
actual_server:
image: docker.io/actualbudget/actual-server:26.7.0
ports:
- "127.0.0.1:15006:5006"
volumes:
- ./actual-data:/data
healthcheck:
test: ["CMD-SHELL", "node scripts/health-check.js"]
interval: 20s
timeout: 10s
retries: 6
start_period: 20s
networks:
- restore_only
restart: "no"
networks:
restore_only:
internal: true
Start only the isolated copy:
sudo docker compose -p actual-restore \
-f compose.restore.yaml up -d --wait --wait-timeout 180
curl -fsS http://127.0.0.1:15006/health
Tunnel workstation port 15006 to the restored listener, sign in, open VPS restore test, and verify the known balance and transaction. If E2EE is enabled, supply its separate password. Do not click Bank Sync; the internal network should make any accidental provider connection fail, but restraint is still part of the test.
Also import one application ZIP as a new budget and compare accounts, balances, categories, and transactions. That proves both recovery paths. Tear down the isolated project after recording the result:
sudo docker compose -p actual-restore -f compose.restore.yaml down
Upgrade with a data-aware rollback
Read the Actual release notes before changing versions. Export every budget, take a stopped full backup, and complete the isolated restore first. Then replace the version tag in compose.yaml with the reviewed target:
cd /opt/actual
sudo docker compose pull actual_server
sudo docker compose up -d --wait --wait-timeout 180
curl -fsS https://budget.example.com/health
curl -fsS https://budget.example.com/info
The info endpoint should report the new version. Repeat login, budget opening, a new transaction, and second-client sync, and check the COOP and COEP headers again. Actual may migrate budget or server data during an upgrade, so switching the image back is not a complete rollback. If the upgrade fails, stop the new container, keep the upgraded /data for diagnosis, and restore the old 26.7.0 image definition together with the pre-upgrade /data archive. Start that matched pair and rerun the acceptance test.
Choosing a Riven Cloud VPS for Actual Budget
Actual Budget is a light workload. For a household or small-team budget server, our Premium plan (2 vCPU, 4 GB RAM, 40 GB NVMe, 1 TB monthly transfer) is plenty, with room left over for a couple of other small containers. Step up to Ultra (2 vCPU, 8 GB RAM, 80 GB NVMe, 2 TB transfer) or Max (4 vCPU, 16 GB RAM, 160 GB NVMe, 4 TB transfer) if you plan to co-host heavier services (our best self-hosted apps guide has candidates worth pairing with a budget server) or want more space for years of budgets and backup archives on the same disk.
Every plan has a 1 Gbps port, full root access, and a choice of Tokyo or Singapore, so pick the location closest to the people who open and sync budgets. If you connect from mainland China, it is worth a minute to run the Tokyo Looking Glass and Singapore Looking Glass from your own carrier before deciding. The Riven Cloud VPS pricing page has the current plan details.
Our plans are unmanaged, so you run the stack yourself and keep full control, and every plan includes daily backups on our side. Those cover whole-host recovery nicely; keep the per-budget ZIP exports, the stopped /data archive, and the restore drill above in your routine as well, since application-level recovery is the part only you can test.
Security limits and non-fit cases
Keep port 5006 on loopback, patch the host and container, restrict Docker access, and monitor failed logins and disk usage. Use unique server and E2EE passwords. Treat bank tokens and every full backup as financial secrets. Do not co-host Actual with untrusted containers that get the Docker socket or broad access to /opt/actual.
Self-hosting Actual works best when someone clearly owns TLS, updates, monitoring, encrypted off-server backups, and restore tests. If your situation calls for managed key custody, audited access controls, high availability, or a contractual SLA, a hosted service with the right bank-integration model will serve you better; optional budget E2EE does not make bank credentials unreadable to the server operator, so it cannot stand in for that kind of control.
Share