Self-host Actual Budget on your own VPS
Run Actual Budget on a VPS with Docker Compose: the data volume, why the browser needs HTTPS, your first budget file, bank imports, and backups.
What you are building
Actual Budget is a self-hosted envelope budgeting app, and it is the usual answer when people go looking for a YNAB alternative they can host themselves. The server is one container, one data volume, and one HTTPS name. Everything a normal budget needs runs comfortably on the smallest VPS you can rent, because the server mostly stores files and syncs them.
The architecture is worth understanding before you type anything. The budget itself is a SQLite database that lives inside your browser and inside each mobile app. The server you are about to install is a sync endpoint: it holds the account list, the budget files, and the change log that lets a phone and a laptop agree. That is why the app still works when the server is down, and it is why losing the server does not lose your budget as long as one client still holds a copy.
Why the server needs HTTPS
Actual asks for HTTPS, and it is not a formality. Browsers only expose the Web Crypto API, the interface Actual uses for its end-to-end encryption, in what the specification calls a secure context. A secure context is https:// or http://localhost. Load the app from http://203.0.113.10:5006 in a browser on another machine and those features are simply not there, because the browser never handed them to the page. The official mobile builds also refuse a plain http:// server URL.
So there are two workable setups. Put a real certificate on a real name in front of the container, which is what this guide does. Or give the server a self-signed certificate with ACTUAL_HTTPS_KEY and ACTUAL_HTTPS_CERT, which the project documents, and accept a browser warning on every device. A free certificate from Let's Encrypt takes five minutes, so take the first option.
Install Actual Budget with Docker Compose
Install Docker first if the box is fresh. If Compose file syntax is new to you, the Docker Compose basics for a VPS guide covers the fields used below.
sudo install -d -m 755 /opt/actual
sudo install -d -m 700 /opt/actual/dataWrite /opt/actual/docker-compose.yml:
services:
actual:
image: actualbudget/actual-server:latest
container_name: actual
restart: unless-stopped
ports:
- '127.0.0.1:5006:5006'
volumes:
- ./data:/dataThree details in that file matter.
The image is actualbudget/actual-server:latest, published by the project to Docker Hub and mirrored at ghcr.io/actualbudget/actual. There is a latest-alpine tag for low-power machines.
The container writes everything under /data. Inside it you get server-files, which holds account.sqlite with your login and session tokens, and user-files, which holds the budget files themselves. Mount that path or the next docker compose pull throws your budget away. ACTUAL_DATA_DIR can move it, but the default is fine.
The port is published on 127.0.0.1 only. A bare 5006:5006 publishes on every interface, and Docker writes its own rules ahead of ufw, so the app would be open to the internet even with a deny-all firewall. That surprise is explained in why Docker published ports bypass ufw. Binding to loopback means only the reverse proxy on the same box can reach it.
Start it:
cd /opt/actual
docker compose up --detach
docker compose logs -f actualThe log settles once the server reports it is listening on port 5006. Check it locally before you touch DNS:
curl -fsS -o /dev/null -w '%{http_code}\n' http://127.0.0.1:5006/A 200 means the app is serving. curl: (7) Failed to connect means the container is not running, and docker compose ps will show it exited. The usual cause is a permission problem on the mounted volume, visible as an EACCES line in the log.
Put a certificate and a real name in front
Point an A record at the VPS, budget.example.com, and wait for it to resolve. Then install nginx and issue the certificate. The Certbot on Ubuntu 24.04 with nginx guide covers issuance and the renewal timer in full.
The proxy block:
server {
listen 443 ssl;
http2 on;
server_name budget.example.com;
ssl_certificate /etc/letsencrypt/live/budget.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/budget.example.com/privkey.pem;
client_max_body_size 100m;
location / {
proxy_pass http://127.0.0.1:5006;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}client_max_body_size is the line people forget. The budget file is uploaded whole on a full sync. Nginx defaults to a 1 MB request body, so once the file grows past that, sync fails with 413 Request Entity Too Large in the nginx access log while the app shows only a generic sync error. The server has its own separate limits: ACTUAL_UPLOAD_FILE_SYNC_SIZE_LIMIT_MB defaults to 20 and ACTUAL_UPLOAD_SYNC_ENCRYPTED_FILE_SYNC_SIZE_LIMIT_MB defaults to 50, so set the nginx limit above whichever one applies to you.
Reload and test:
sudo nginx -t && sudo systemctl reload nginx
curl -fsS -o /dev/null -w '%{http_code}\n' https://budget.example.com/First run: the password and your first budget file
Open https://budget.example.com in a browser. The first screen asks you to set a server password. That single password guards the whole server, so generate a long random one and keep it somewhere you will find it again, such as a self-hosted Vaultwarden password manager. There are no user accounts to create. Actual's server is single-password by design, so sharing a budget means sharing that password.
Then create a budget file. Actual asks whether to enable end-to-end encryption. Say yes and the server stores only ciphertext, which is the right answer for financial data on a rented machine. The cost is real: the encryption password never reaches the server, so if you lose it the file is gone and no reset exists. Write it down before you click past that screen.
Set your starting balances from your bank's current figures rather than importing years of history. Envelope budgeting works forward from the money you have now, so an empty history costs you nothing.
Getting transactions in
This is where honesty matters more than enthusiasm, because the import story is the main reason people bounce off self-hosted budgeting.
Manual entry is the baseline and it always works. For an envelope method it is arguably the point, since typing a purchase is what makes you notice it.
File import handles the bulk. Actual reads CSV, QIF, OFX and QFX, and every bank exports at least one of those. Import per account from the account screen, map the columns once, and Actual remembers that layout for the account.
Automatic bank sync exists, and it needs a third-party service because the server cannot talk to banks on its own. Actual supports SimpleFIN Bridge for North American banks, Enable Banking for Europe, Akahu for New Zealand, and Pluggy.ai for Brazil. GoCardless is still supported but is not accepting new accounts. You sign up with the provider yourself, generate credentials, and add them to the server. SimpleFIN Bridge charges 15 US dollars per year for up to 25 institutions as of July 2026, and the others price differently.
Two limits to accept before you rely on this. The API credentials sit on the server and are not covered by end-to-end encryption, because the server has to use them. And Actual does not poll: syncing is a button you press, not a background job.
Backups, because it is only files
Everything you care about is under /opt/actual/data. There is no export step and no database dump to script.
The one trap is SQLite. Copying account.sqlite while the server is writing to it can capture a half-finished transaction, and you will not find that out until you try to restore. Stop the container for the few seconds the copy takes:
cd /opt/actual
docker compose stop
restic -r sftp:backup@backup.example.com:/srv/restic backup /opt/actual/data
docker compose startPut that on a schedule with the approach in restic backups on a VPS, which covers repository setup, retention and the restore drill. Run the restore drill. A backup you have never restored is a guess.
Actual's own client-side backups are a separate thing and worth knowing about. The browser keeps recent copies of the budget file, reachable from the file menu, which covers "I deleted a category by mistake" without touching the server at all.
Updating the server
cd /opt/actual
docker compose pull
docker compose up --detachCompose recreates the container from the new image and reattaches the same volume, so the data survives. Update the clients too. Server and app versions are expected to stay close, and a client much older than the server can refuse to sync with a version mismatch message. Take a backup before a major version jump, because migrations run on first start and there is no downgrade path.
What breaks, and what you will see
The app loads but sync never finishes. Check the nginx access log for 413. That is client_max_body_size set too low. A 502 instead means nginx is up and the container is not.
Encryption options are missing, or the mobile app refuses the URL. The page is not in a secure context. The address bar will show http:// with an IP address or a hostname that is not localhost. Fix the certificate rather than working around it.
A message that the budget file is not compatible with this version. Client and server versions have drifted apart. Update both to the same release and reload.
The container restarts in a loop. Read docker compose logs actual. A permission error on /data means the mounted directory is not writable by the container's user. An address-in-use error means something else already holds 5006 on loopback.
First load feels slow. The whole budget file downloads to the browser when you open it. That is one large transfer, then local reads. It is not a server sizing problem, and adding RAM will not change it.
FAQ
Does Actual Budget need HTTPS to work?
Yes, in practice. Actual's end-to-end encryption uses the browser's Web Crypto API, and browsers only expose that in a secure context, meaning https:// or http://localhost. Over plain HTTP from another machine those features are unavailable, and the official mobile apps refuse a plain HTTP server URL. Use a Let's Encrypt certificate on a real hostname, or a self-signed certificate with ACTUAL_HTTPS_KEY and ACTUAL_HTTPS_CERT if you only ever use a desktop browser.
Can Actual import my bank transactions automatically?
Only through a third-party service you sign up for yourself: SimpleFIN Bridge in North America, Enable Banking in Europe, Akahu in New Zealand, or Pluggy.ai in Brazil. GoCardless is supported but is not accepting new accounts. Those API credentials live on your server and are not covered by end-to-end encryption. Sync is manual as well, so you press a button and nothing polls in the background. CSV, QIF, OFX and QFX import needs no third party at all.
What exactly do I have to back up?
The mounted data directory, which is /opt/actual/data in this guide. It contains server-files/account.sqlite with logins and sessions, and user-files with the budget files. Stop the container before copying, because copying a live SQLite database can capture a partial write. Nothing else on the server holds state.
What happens if I lose the encryption password?
The file cannot be recovered. The password never reaches the server, which is the whole point of end-to-end encryption, so there is no reset and no support path. Store it in a password manager the moment you create the file, and keep a copy somewhere that does not depend on this same server.
How much server does Actual Budget need?
Very little. The container serves static assets and files, and the budget calculations happen in the browser. One shared vCPU with 1 GB of RAM runs it without complaint, and the data directory for a household budget with several years of history stays in the tens of megabytes. Disk pressure comes from your backups and your other containers, not from Actual.