SSD Nodes Learn Hosting plans →
Guides Matt ConnorBy Matt Connor

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. Actual needs a browser feature called SharedArrayBuffer to run at all, and browsers only enable it when the page is cross-origin isolated 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 the browser never hands that feature to the page, so the budget does not load. Phones get no exception. Actual's FAQ says an official iOS app is in beta testing and connects to your own server, and that there is no official Android app yet, so on most phones you use the same web app, installed to the home screen as a Progressive Web App, and the same secure-context rule applies.

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/data

Write /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:/data

Three 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 actual

The 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 nothing accepted the connection on 127.0.0.1:5006. Run docker compose ps --all, because plain docker compose ps lists only running containers. If the container shows as 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. Day-to-day sync sends only your changes, but the whole budget file is uploaded when you first register a budget with the server and again whenever you reset sync. Nginx defaults to a 1 MB request body, so once the file grows past that, those full uploads fail 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. Out of the box there are no user accounts to create: the server has one password, so sharing a budget means sharing that password. Separate logins are possible only if you connect an OpenID provider, which Actual supports for multi-user setups.

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, QFX and CAMT, and every bank exports at least one of those. Import per account from the account screen, and map the columns when the file is a CSV.

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 plus tax for up to 25 institutions as of October 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 nothing syncs while Actual is closed: automatic syncing is off by default, and once you turn it on it runs only while the app is open, so the server never polls your banks on its own.

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 start

Put 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 automatic backups are a separate thing and worth knowing about, but they exist only in the desktop app. It keeps up to 10 recent copies of the budget file, reachable from File > Load Backup, which covers "I deleted a category by mistake" without touching the server at all. The web app in a browser keeps no such copies, so if everyone in the house uses the browser, the server backup above is the only one you have.

Updating the server

cd /opt/actual
docker compose pull
docker compose up --detach

Compose recreates the container from the new image and reattaches the same volume, so the data survives. Update the clients too. A client older than the data it receives stops applying changes and shows an Update Required message. Take a backup before a major version jump, because migrations run on first start and there is no downgrade path. Actual is forgiving about a floating latest tag because its state is a directory of files, while an app carrying a real database is not, and self-hosting Chatwoot walks through the pinned tags and pre-upgrade dump that habit calls for.

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 did not get a usable response from the upstream. Most often that is because the container is stopped or not listening on 5006, so check docker compose ps --all and the container log before anything else.

The app will not load on another device, or reports that SharedArrayBuffer is missing. 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. The file has been opened in a newer version of Actual than the client you are using now. Update the client to at least that version, or restore a backup taken before the newer version touched the file, then 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 needs the browser's SharedArrayBuffer feature to run, and browsers only enable it in a secure context, meaning https:// or http://localhost. Over plain HTTP from another machine the app will not load. An official iOS app is in beta testing and there is no official Android app yet, so most phones use the same web app and hit the same rule. (FAQ) 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 can live with a certificate warning on every device.

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. Automatic syncing is off by default and, once enabled, runs only while Actual is open, so nothing polls in the background on the server. CSV, QIF, OFX, QFX and CAMT 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. If you are sizing a box that has to run something hungrier alongside it, a photo server is usually what sets the floor, so check how much RAM PhotoPrism and Immich actually need before you pick a plan. The same logic covers a media stack: transcoding decides the plan, while a browser front end such as Halcyon, which turns a Jellyfin library into a walkable 90s video store costs about as little as Actual does.

Was this article useful?