Most budgeting apps want your bank logins. They sell aggregated spending data, push you toward a subscription, and shut down the free tier the moment they get traction. Actual Budget takes the opposite approach: it's a fast, local-first, zero-based budgeting app that you run yourself. Your transactions live in a SQLite database on your own server, sync is optional and can be end-to-end encrypted, and there's no monthly fee.
Actual is the open-source descendant of a commercial app that the original author bought back and released under the MIT license. The sync server ships as a single Docker container, runs comfortably on a 512 MB VPS, and serves both the web UI and the desktop and mobile clients. This guide takes you from a fresh Ubuntu box to a working budget at budget.example.com with HTTPS, a login password, end-to-end encryption, and automated backups.
budget.example.com at the serveractualbudget/actual-server:latest behind Caddy in one docker-compose.yml/data volume nightly and sync it off-siteTotal time: about 15 minutes.
80 and 443 open to the internet for Let's EncryptActual is light. An idle server with a couple of budget files sits around 80-120 MB of RAM. The single container handles the API, the web app, and the sync engine, so there's no separate database or cache to run. If you already host other Docker workloads behind Caddy, this slots in next to them on a new subdomain.
Worth understanding before you start, because it shapes how you back things up.
Actual is local-first. Each client (the web app in your browser, the desktop app, the mobile app) keeps a full copy of your budget and does all the math locally. The server's job is not to be the source of truth - it's a sync relay that stores an encrypted message log and hands it to other devices. That's why the app feels instant even on a slow connection, and why it keeps working offline.
Two practical consequences:
In your DNS provider, add an A record:
budget.example.com → YOUR_VPS_IPV4
Add an AAAA record too if your server has IPv6. Verify it resolves before going further:
dig +short budget.example.com
DNS has to point at your VPS before Caddy can fetch a TLS certificate from Let's Encrypt.
On a fresh Ubuntu box:
sudo apt update
sudo apt install -y ca-certificates curl
sudo install -m 0755 -d /etc/apt/keyrings
sudo curl -fsSL https://download.docker.com/linux/ubuntu/gpg \
-o /etc/apt/keyrings/docker.asc
sudo chmod a+r /etc/apt/keyrings/docker.asc
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] \
https://download.docker.com/linux/ubuntu $(. /etc/os-release && echo $VERSION_CODENAME) stable" | \
sudo tee /etc/apt/sources.list.d/docker.list > /dev/null
sudo apt update
sudo apt install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
Confirm it works:
sudo docker run --rm hello-world
Keep everything in one place so backups and upgrades are predictable:
sudo mkdir -p /opt/actual/data
cd /opt/actual
The data directory is where Actual stores the server SQLite database, your budget files, and uploaded account configs. It's the one directory you need to back up.
Create /opt/actual/docker-compose.yml:
services:
actual:
image: actualbudget/actual-server:latest
container_name: actual
restart: unless-stopped
environment:
- ACTUAL_UPLOAD_FILE_SYNC_SIZE_LIMIT_MB=20
- ACTUAL_UPLOAD_SYNC_ENCRYPTED_FILE_SYNC_SIZE_LIMIT_MB=50
volumes:
- ./data:/data
ports:
- "127.0.0.1:5006:5006"
healthcheck:
test: ["CMD-SHELL", "node /app/src/scripts/health-check.js"]
interval: 30s
timeout: 10s
retries: 3
caddy:
image: caddy:2
container_name: actual-caddy
restart: unless-stopped
depends_on:
- actual
ports:
- "80:80"
- "443:443"
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- caddy_data:/data
- caddy_config:/config
volumes:
caddy_data:
caddy_config:
Note the Actual container binds to 127.0.0.1:5006, not 0.0.0.0. Only Caddy talks to it directly, and Caddy is the single front door for TLS. Nothing on port 5006 is reachable from the public internet.
Create /opt/actual/Caddyfile:
budget.example.com {
encode zstd gzip
reverse_proxy actual:5006
}
That's the whole file. Caddy resolves the actual hostname over Docker's internal network, fetches a Let's Encrypt certificate for budget.example.com on first boot, and renews it automatically. If you want to read more about Caddy patterns, see our Caddy reverse proxy guide.
cd /opt/actual
sudo docker compose up -d
Watch the logs until Caddy reports a certificate and Actual is listening:
sudo docker compose logs -f
You're looking for a line from Actual like Listening on :::5006 and a Caddy line about obtaining a certificate for your domain. Press Ctrl+C to stop following the logs - the containers keep running.
Open https://budget.example.com in a browser. You should see the Actual welcome screen asking you to set a server password.
The first thing Actual asks for is a server password. This is the gate to the whole instance - anyone who knows it can open the budget UI and create or load budget files. Pick a strong, unique password and store it in your password manager.
This password is separate from the end-to-end encryption password you'll set in the next step. The server password controls access to the app; the encryption password controls whether the server can read your data. Keeping them different is good practice.
After setting it, Actual drops you on an empty dashboard with a button to create your first budget file.
Click Create new budget. Give it a name like Household 2026. You now have a working budget file syncing to your server.
Before you enter any real data, enable end-to-end encryption so the server only ever stores ciphertext:
From now on, each new device you connect will prompt for this encryption password the first time it downloads the file.
The same server works for every client:
https://budget.example.com from any browser and log in with the server password.https://budget.example.com.Each device downloads the encrypted budget, prompts once for the encryption password, then syncs in the background. Edits made offline reconcile automatically when the device reconnects.
Actual is built around zero-based, envelope-style budgeting, but you don't have to start from scratch:
For ongoing automatic imports, the community maintains actual-ai integrations and bank-sync bridges (GoCardless and SimpleFIN), but plenty of people just import a CSV once a week. Manual entry on mobile is fast enough that many users never automate it.
A few quick hardening steps so a budgeting app isn't your weakest link:
127.0.0.1 bind in the compose file. Double-check with sudo ss -tlnp | grep 5006 - it should show 127.0.0.1:5006, never 0.0.0.0:5006.sudo ufw allow OpenSSH
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw enable
A personal budget has an audience of one. There's no reason for the login page to be reachable by the entire internet. The cleanest setup is to put Actual behind a VPN so budget.example.com only answers inside your private network.
Pair this guide with our Tailscale guide: install Tailscale on the VPS, bind Caddy to the Tailscale interface, and your budget becomes invisible to the public internet while staying fully usable from your laptop and phone. The desktop and mobile clients work fine over WireGuard or Tailscale.
If you keep it public, at minimum enable end-to-end encryption (Step 8) so a server compromise leaks ciphertext, not your finances.
Even though every client holds a copy, you want server-side backups you control. The /opt/actual/data directory contains everything. Create /usr/local/bin/actual-backup.sh:
#!/usr/bin/env bash
set -euo pipefail
BACKUP_DIR="/var/backups/actual"
DATE="$(date +%Y-%m-%d-%H%M)"
mkdir -p "$BACKUP_DIR"
docker exec actual sqlite3 /data/server-files/account.sqlite ".backup /data/server-files/account-backup.sqlite"
tar -czf "$BACKUP_DIR/actual-$DATE.tar.gz" -C /opt/actual data
find "$BACKUP_DIR" -name "actual-*.tar.gz" -mtime +14 -delete
The sqlite3 .backup call produces a consistent snapshot of the server database even while Actual is running, which is safer than tarring a live file mid-write. Make it executable and schedule a nightly run:
sudo chmod +x /usr/local/bin/actual-backup.sh
echo "30 3 * * * root /usr/local/bin/actual-backup.sh" | \
sudo tee /etc/cron.d/actual-backup
For off-site safety, push /var/backups/actual to object storage. Our restic to S3 guide covers encrypted, deduplicated backups to any S3-compatible bucket on the same nightly schedule.
To restore: stop the stack, replace data/ with the contents of a backup tarball, start the stack, and reload the budget on a client.
Actual ships frequent releases. The safe path:
cd /opt/actual
sudo docker compose pull
sudo docker compose up -d
Always take a fresh backup first. Major releases occasionally bump the sync protocol; if a client refuses to sync after an upgrade, update that client app to the matching version. Pinning to actualbudget/actual-server:latest keeps server and the official clients in step, but a known-good backup makes any rollback painless.
Caddy returns 502 Bad Gateway. The Actual container hasn't finished starting, or it crashed. Check sudo docker compose logs actual. A 502 right after docker compose up usually just means Caddy beat Actual to the punch - wait 15 seconds and refresh.
"Failed to connect to server" in the desktop or mobile app. Confirm the server URL includes https:// and no trailing path. Test from the same network with curl -I https://budget.example.com - you want a 200 or 302, not a connection refused.
Caddy can't issue a certificate. DNS hasn't propagated, or your provider blocks 80/443. Verify with dig +short budget.example.com and curl -I http://budget.example.com. Let's Encrypt needs port 80 reachable for the HTTP challenge.
A device asks for the encryption password and rejects the right one. You're likely typing the server password instead. They're different secrets. If you genuinely lost the encryption password, the data is unrecoverable by design - restore from a pre-encryption backup if you have one.
Sync fails with a file-size error. A large budget with many attachments can exceed the default upload limit. Raise ACTUAL_UPLOAD_SYNC_ENCRYPTED_FILE_SYNC_SIZE_LIMIT_MB in the compose file and docker compose up -d to apply it.
Self-hosted Actual Budget is the rare finance tool that gets out of your way. No upsells, no data harvesting, no surprise sunset email. You own the database, the encryption keys, and the backups - and it runs on a VPS small enough to cost less than a single month of a commercial budgeting subscription.
Need a small, reliable VPS to run a stack like this? Our Linux plans include fast NVMe storage, IPv6, and plenty of headroom for Docker workloads. See the options.