How to Deploy Actual Budget on a VPS
Updated Sep 2026
verified on Ubuntu 26.04 · Sep 2026Self-host the Actual Budget sync server on a small VPS: one container, one data folder, and the HTTPS step the app will not run without.
- A small VPS: 1 vCPU / 1 GB RAM is plenty
- A fresh Ubuntu 24.04 or 26.04 server with root/sudo SSH access
- A domain you can point at the server. HTTPS is required, not optional
- Docker Engine + Compose installed (see the base guide below)
What Actual Budget is
Actual Budget is a local-first envelope budgeting app under the MIT licence. "Local-first" is literal: your budget lives in a SQLite database inside the browser or desktop app, and every change is applied there first. The piece you host is the sync server, a small Node.js service that stores each budget file and relays changes between your devices. It also serves the web app itself, so one container gives you both.
That design shapes the whole deploy. The server is not doing heavy work, so it fits on the smallest box you can buy. But the web app runs its database engine in your browser, and browsers only allow that on a secure origin. Without HTTPS, Actual does not start. Most of the care in this guide goes into that one step.
If you are choosing between budgeting tools, Actual Budget vs Firefly III covers the trade-off: Actual is envelope budgeting you do by hand, and Firefly III is double-entry bookkeeping with rules and imports.
Server sizing
The catalog lists 512 MB RAM as the minimum. On our test box (GCP e2-standard-2, Ubuntu 26.04, Docker 29.8.1) the idle container measured about 241 MB of RAM and about 505 MB of disk for the image and data.
- 1 vCPU / 1 GB RAM is enough for a household, with room for Caddy in front.
- Disk is set by the size of your budget files, which are small. 10–20 GB covers the OS, the image and years of history.
Put it on a reliable host. This box holds your financial history, so a provider with dependable uptime and disk snapshots is worth more here than a rock-bottom plan.
Prepare the server
This guide assumes Docker Engine and the Compose plugin are installed, along
with a non-root user and a ufw firewall. If not, work through
Docker & Compose on Ubuntu first.
Open SSH and the reverse proxy ports only. Actual's own port stays on the loopback interface:
sudo ufw allow OpenSSH
sudo ufw allow 80
sudo ufw allow 443
sudo ufw --force enable
sudo ufw status verbose
Paid link — we earn a commission if you shop through it.
Install Actual (Docker Compose)
Upstream publishes the image as actualbudget/actual-server on Docker Hub and
as ghcr.io/actualbudget/actual on GitHub's registry. The latest tag is the
most recent stable release and is what the docs recommend for most people.
Create a project folder:
mkdir -p ~/actual && cd ~/actual
Write the compose file. It is the one from the Actual repository, with the
port bound to 127.0.0.1 so that only the reverse proxy can reach it:
cat > docker-compose.yml <<'YAML'
services:
actual_server:
image: docker.io/actualbudget/actual-server:latest
container_name: actual_server
restart: unless-stopped
ports:
# Loopback only: Caddy is the only way in from outside.
- "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
YAML
The ./actual-data folder is the whole application state. The server creates
server-files (the account database, including your server password) and
user-files (the synced budget files) inside it.
Start it and wait for the server to answer:
docker compose up -d
timeout 120 bash -c 'until curl -fsS -o /dev/null http://127.0.0.1:5006/; do sleep 3; done'
docker compose ps
At this point the server is running but only reachable from the box itself. That is intended.
HTTPS + domain (required)
Actual's upstream docs are blunt about this: the app needs a browser feature
called SharedArrayBuffer, browsers only enable it on HTTPS pages, and
Actual will not run unless the server meets those conditions. The server
already sends the other two headers it needs (Cross-Origin-Embedder-Policy
and Cross-Origin-Opener-Policy), so a valid certificate is the only thing you
have to add.
Point an A record such as budget.example.com at the server's public IP
and wait for it to resolve. Then put Caddy in front of the loopback port, as in
Automatic HTTPS with Caddy:
budget.example.com {
encode gzip zstd
reverse_proxy 127.0.0.1:5006
}
This matches the Caddy example in Actual's reverse-proxy docs. The only change
is the upstream address, because Caddy here runs on the host. If you run Caddy
as a container instead, 127.0.0.1 is the container's own loopback. Put both
services in one compose file, proxy to actual_server:5006, and drop the host
port mapping.
On nginx, do not add the COOP/COEP headers yourself. Actual sets them. If
nginx adds them as well, the browser sees duplicate headers, rejects the
policy, and the app stops with a SharedArrayBufferMissing error. Upstream's
fix is proxy_hide_header for those two headers, so that one source sets them.
First run: set the server password
Open https://budget.example.com. On a fresh server, Actual first asks you to
create the server password. That password gates access to every budget on
the server. There are no user accounts to manage, and no open signup to close.
Then create a budget, or import one. Actual's docs cover importing from YNAB 4, nYNAB and another Actual instance.
Two settings are worth making on day one:
- End-to-end encryption. In a budget's settings, "enable encryption" asks for a second password and encrypts the file before it leaves your device, so the server only stores data it cannot read. Upstream's warning is plain: forget that password and the data cannot be recovered, and encryption cannot be turned off again later. Use a password manager.
- Connect your other devices to the same server URL. Each device keeps a full local copy and syncs through the server.
Securing it
- Keep the port on loopback. The
127.0.0.1:5006mapping means the server is reachable only through Caddy and its certificate. - Use a long server password. It is the only login on the server.
- Leave
ACTUAL_LOGIN_METHODat its default unless you know you need something else. Theheadermethod logs in anyone who can send anx-actual-passwordheader, which upstream flags as advanced with "security implications". OpenID login exists but is marked as a preview. - Forgot the server password? It can be reset without touching your
budgets. Run
docker exec -it actual_server /bin/sh, then runnode /app/src/scripts/reset-password.jsinside the container and answer the prompts.
Backups
Two layers, because the app is local-first.
Server-side: the whole server is the ./actual-data folder. Stop the
container for a clean copy of the SQLite files:
cd ~/actual
docker compose stop
tar czf actual-data-$(date +%F).tar.gz actual-data
docker compose start
Copy the archive off the box. Restoring is the same folder put back in
place, followed by docker compose up -d. The container writes those files as
root, so replacing or deleting the folder needs sudo.
In-app: under Settings, the Export section's Export Data button downloads a copy of the open budget. It is a portable backup that does not depend on this server at all. Make one before any upgrade you are nervous about.
Upgrades
cd ~/actual
docker compose pull
docker compose up -d
This is the update procedure from the Actual docs. latest follows stable
releases. The nightly tag tracks the development branch, and upstream asks
anyone running it to keep backups, so stay on latest for a budget you rely
on. Take the server-side backup above first.
Troubleshooting
The page loads, then shows a fatal error about SharedArrayBuffer. You are
on plain HTTP, or on a proxy that duplicates the COOP/COEP headers. Load the
site over https:// with a valid certificate. On nginx, hide the upstream
headers as described above.
"Cannot reach server" from a device. The server URL on that device must
match exactly: https://budget.example.com, with no port. Check that DNS
resolves to the box and that ports 80 and 443 are open in ufw and in any
cloud firewall.
The container shows unhealthy. Read docker compose logs actual_server.
The usual cause is a data folder the container cannot write to. Check
ownership of ./actual-data.
Upload fails on a large budget. The server limits sync uploads to 20 MB by
default. Raise ACTUAL_UPLOAD_FILE_SYNC_SIZE_LIMIT_MB (and the matching
ACTUAL_UPLOAD_FILE_SIZE_LIMIT_MB) in the compose file's environment. Or use
"Reset Sync" under the advanced settings, which upstream notes compacts the
file.
Verification + next steps
You're done when you can load https://budget.example.com over a valid
certificate, sign in with the server password, open the same budget on a
second device, and see a change on one appear on the other. Then make an
off-box copy of actual-data and check that it contains server-files and
user-files.
From there: turn on end-to-end encryption if the server is shared or hosted by a provider you don't fully trust, and schedule the backup with cron. If you want investment tracking next to the budget, Ghostfolio fits beside it on the same box. For full double-entry bookkeeping, see Deploy Firefly III on a VPS.