Can I self-host YNAB?

YES · ONE COMMAND— setup effort 1 of 4

YES — it's called Actual Budget. It takes one prompt, a 512 MB VPS, and about 10 minutes. That is $14.99 a month you stop paying YNAB — $179.88 a year on the Monthly plan.

  • ynab.com
  • Money & home
  • prices checked 2026-08-05

Why people pay for YNAB

Stated as the vendor would want it stated. A replacement you pick without knowing what the subscription actually buys is a replacement you abandon in a fortnight.

YNAB sells a method more than software. Give every dollar a job, and the app is the thing that keeps nagging you until you have. The bank sync, the apps on every phone in the house and the teaching material around it are what make the method survive contact with a bad month, and that is the part a subscription pays for.

YNAB plans and list prices
PlanList priceWhat it buys
Monthlythe plan this page prices against$14.99/moBilled monthly, USD, before tax. One subscription is shared with up to six people.
Annual$9.08/mo$109 USD paid annually, which the page states as $9.08 a month. Same product, twelve months up front.

Vendor list prices in USD, read from the pricing page on 2026-08-05 · confidence: high

Replaced by Actual Budget

One project, named before the prompt, so you know what you are about to install.

Zero-based envelope budgeting in one container, with the budget file on your disk and no subscription attached to your money.

It is a direct descendant of the same envelope method, down to the rule that every dollar gets assigned before it can be spent, and it keeps the whole budget in a local-first file that syncs through a server you run. The gap is bank sync: YNAB's connections are included, and Actual reaches banks only through a third-party aggregator you set up and, in most regions, pay for separately.

The swap

You're paying

YNAB

$14.99/mo · $179.88/yr

is replaced by

You'd run

Actual Budget

ONE COMMAND · ~10 min to running · 512 MB RAM

YNAB Monthly · vendor list price · checked 2026-08-05 · source

Before you start

RAM floor
512 MBfloor from upstream docs — not measured by us yet
Disk
5 GBthe app, its data, and room for one backup
Domain needed
yes, one A recorda hostname pointed at the box before you start — TLS needs it
Time budget
~10 minunder 10 minutes, through the first backup

The prompt

One prompt, assuming Prompt Zero is done. It installs Actual Budget — read it before you paste it, which is the whole reason it is on the page instead of behind a download.

authored from upstream docs · not yet machine-verified · Claude Code

248 lines · 10,537 bytes

What this prompt will do
  1. Preflight
  2. Layout
  3. Secrets
  4. compose.yml
  5. Caddy and TLS
  6. Firewall
  7. Start and verify
  8. First backup and restore
  9. Updating later
  10. What will probably go wrong
  11. Out of scope

Read out of the prompt’s own step headings at build time — if the prompt changes, this list changes with it.

paste it into Claude Code in a terminal on your own machine · it runs the install over ssh vps

You are Claude Code on the user's machine. The user has completed Prompt Zero: `ssh vps` works,
Docker and Caddy are installed, the firewall is default-deny.

Run every command in this prompt on the server over `ssh vps` unless the step says otherwise.

Install Actual Budget 26.8.0 on that server, reachable at https://<DOMAIN>, behind the existing
Caddy with automatic TLS.

## 1. Preflight

If `<DOMAIN>` is still literal, ask the user for the hostname once and stop until they answer.
Its A record must already point at this server. Actual is a small Node process, so it needs
512 MB of RAM available and 5 GB free on /srv, and the 26.8.0 image covers amd64 and arm64.

```bash
free -m | awk '/^Mem:/ {print $7 " MB available of " $2 " MB"}'
df -BG --output=avail /srv | tail -1
dpkg --print-architecture
dig +short <DOMAIN>
```

If RAM is under 512 MB or disk under 5 GB, print both numbers and stop. If `dig +short` prints
nothing, print that and stop: Caddy cannot certify a hostname that does not resolve.

## 2. Layout

The image creates an `actual` account with uid 1001 and runs as it, so the data directory
belongs to 1001 and not to the login user.

```bash
sudo install -d -m 750 -o $(id -u) -g $(id -g) /srv/actual-budget /srv/actual-budget/backups
sudo install -d -m 750 -o 1001 -g 1001 /srv/actual-budget/data
ls -la /srv/actual-budget
```

Assert: `ls -la` shows `backups` owned by the login user and `data` owned by `1001`. Nothing is
written outside /srv/actual-budget.

## 3. Secrets

No secret is generated for this install, and there is no `.env` file. Actual has exactly one
credential, the server password, and it is chosen by the user in a browser at step 7 rather
than written into a file here. That is why this block has nothing to run.

Tell the user two things now, before they choose it. That one password is the whole door: it
guards every budget file on the server. And end-to-end encryption is a separate, per-file
setting inside Actual, off by default, so until they turn it on the budget data on this disk is
readable by anyone who can read the disk.

## 4. compose.yml

```bash
cat > /srv/actual-budget/compose.yml <<'EOF'
# Actual Budget · the deterministic fallback. Authored by caniselfhostit from
# the upstream documentation, not copied from a repository:
#   image, port, /data .. https://actualbudget.org/docs/install/docker
#   configuration ....... https://actualbudget.org/docs/config/
#   health route ........ https://github.com/actualbudget/actual/blob/master/packages/sync-server/src/scripts/health-check.js
#
# One container, no database process and no secret to generate: the sync server
# keeps account.sqlite and the budget blobs under /data, and the only credential
# is the server password you set in a browser at step 7. The image runs as uid
# 1001, hence the ownership in step 2. Tag and digest are the 26.8.0 release read
# from Docker Hub on 2026-08-05, for linux/amd64 and linux/arm64.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  actual:
    image: actualbudget/actual-server:26.8.0@sha256:0b300f370dba85a74998a953736a831bd931cc8cb76c0d8ceac3d3fd288dfd4d
    container_name: actual
    restart: unless-stopped
    environment:
      # Caddy reaches the published port from the host, so the container sees
      # the Docker bridge as the client. Naming that range keeps the rate
      # limiter counting real clients instead of one proxy.
      ACTUAL_TRUSTED_PROXIES: 172.16.0.0/12
    volumes:
      # server-files holds account.sqlite, user-files holds the budget blobs.
      # Local disk only: SQLite needs real POSIX file locks to stay intact.
      - /srv/actual-budget/data:/data
    ports:
      # Loopback only. The Caddy that Prompt Zero installed on the host is the
      # only thing that can reach this port, and 8090 never enters the firewall.
      - "127.0.0.1:8090:5006"
EOF
cd /srv/actual-budget && docker compose config >/dev/null && echo "compose OK"
```

Assert: that prints `compose OK`. The container serves on 5006 inside itself and 8090 is bound
to 127.0.0.1 on the host, so the only route in is Caddy. Upstream's example publishes 5006 on
every interface, which is convenient on a laptop and wrong on a machine with a public IP.

## 5. Caddy and TLS

Append the block below with `<DOMAIN>` replaced by the real hostname. Copy the file first: a
syntax error takes down every site on the box.

```bash
sudo cp /etc/caddy/Caddyfile /etc/caddy/Caddyfile.before-actual-budget
printf '\n' | sudo tee -a /etc/caddy/Caddyfile >/dev/null
sudo tee -a /etc/caddy/Caddyfile >/dev/null <<'EOF'
# Actual Budget · the Caddy site block for this service.
#
# Authored by caniselfhostit from https://caddyserver.com/docs/automatic-https
# and https://actualbudget.org/docs/install/docker
#
# Append this to /etc/caddy/Caddyfile, with <DOMAIN> replaced by the hostname
# pointed at this box. Caddy runs under systemd. No Caddy container here.

<DOMAIN> {
	encode zstd gzip

	header {
		Strict-Transport-Security "max-age=31536000; includeSubDomains"
		X-Content-Type-Options "nosniff"
		X-Frame-Options "SAMEORIGIN"
		Referrer-Policy "no-referrer"
		-Server
	}

	# 8090 is the loopback port compose publishes; it is never in the firewall.
	# A full budget upload arrives as one request, so no body limit is set here
	# and ACTUAL_UPLOAD_FILE_SYNC_SIZE_LIMIT_MB stays at the upstream default.
	reverse_proxy 127.0.0.1:8090
}
EOF
sudo caddy validate --config /etc/caddy/Caddyfile
sudo systemctl reload caddy
```

Assert: both exit 0. If validate fails, restore /etc/caddy/Caddyfile.before-actual-budget,
reload, and report what it objected to. Caddy gets the certificate on the first request and
renews it with no cron job. Actual runs HTTPS itself if you hand it a key and certificate; this
install does not, because Caddy already holds one and two certificate owners on one box is a
renewal argument waiting to happen.

## 6. Firewall

Two ports open, both Caddy's. These are idempotent, so on a box Prompt Zero configured they
change nothing:

```bash
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw allow 443/udp
sudo ufw status verbose
```

80/tcp answers the ACME challenge and redirects to HTTPS, 443/tcp is the only way in, 443/udp
is HTTP/3. 8090 stays closed: bound to 127.0.0.1, a rule for it would cover traffic that cannot
arrive, and if it appears there a previous run left it, which `sudo ufw delete allow 8090`
fixes. Assert: `ufw status verbose` prints `Status: active`, shows 80, 443/tcp and 443/udp, and
no rule for 8090.

## 7. Start and verify

```bash
cd /srv/actual-budget
docker compose up -d
sleep 15
curl -sS https://<DOMAIN>/health
echo
curl -sS https://<DOMAIN>/account/needs-bootstrap
echo
```

Assert, both: `/health` prints JSON containing `"status":"UP"`, and `/account/needs-bootstrap`
prints JSON containing `"bootstrapped":false`. Print exactly what you received for each. If
either misses, stop, run `docker compose logs --tail 30 actual`, and name the likely earlier
step. A running container is not success. `bootstrapped:false` means the server is currently
open to whoever loads the page first, which is why the next line is a hard stop.

The first screen at https://<DOMAIN> asks the user to choose a password for this server.

STOP: tell the user to open https://<DOMAIN> now, set that password, and save it in their
password manager. Wait. Do not continue until they confirm.

```bash
curl -sS https://<DOMAIN>/account/needs-bootstrap
echo
```

Assert: this now prints `"bootstrapped":true`. That flip is the security assert for this
install: until it is true, anyone who finds the hostname owns the budget. If it still says
false, the password was not set, and nothing else matters yet.

## 8. First backup and restore

Take the backup now, before the user imports a single transaction. Stop first: a SQLite file
copied mid-write is not a backup.

```bash
cd /srv/actual-budget
docker compose stop
sudo tar -C /srv/actual-budget -czf /srv/actual-budget/backups/actual-budget-$(date +%F).tar.gz data
docker compose start
ls -lh /srv/actual-budget/backups/
```

Assert: the archive exists and is non-empty. Print its size. `data` is the whole install: there
is no `.env` here, and `data/server-files/account.sqlite` holds the password hash while
`data/user-files` holds the budgets. A backup on the same disk is not a backup, so run this
from the user's machine:

```bash
mkdir -p ~/backups/actual-budget
scp vps:/srv/actual-budget/backups/*.tar.gz ~/backups/actual-budget/
```

To restore: `docker compose down`, `sudo rm -rf /srv/actual-budget/data`,
`sudo tar -C /srv/actual-budget -xzf` the archive, then `docker compose up -d`. Those four
commands are the whole disaster plan. Tell the user Actual also exports a plain zip of any
budget from inside the interface, and that a monthly one of those in a different place is worth
more than any of this, because it is readable without a server.

## 9. Updating later

New versions are at https://github.com/actualbudget/actual/releases. Take a backup first, then
edit the image line in /srv/actual-budget/compose.yml to the new tag and digest. Actual migrates
its own database on the next boot, and the browser holds a cached copy of the app, so load the
page and hard-refresh once before calling this done.

```bash
cd /srv/actual-budget
docker compose pull
docker compose up -d
docker compose logs --tail 20 actual
```

## 10. What will probably go wrong

Nothing during the install, and then the user asks where their bank is. Actual does not connect
to banks on its own: it talks to GoCardless or SimpleFIN, each of which is a separate signup
with its own credentials, and in the United States the usable one is not free. I finished this
install in under ten minutes and then spent an hour discovering that the part I actually wanted
was a different product with a different bill. Tell the user before they start moving their
budget across, not after.

## 11. Out of scope

- Do not set `ACTUAL_HTTPS_KEY` or `ACTUAL_HTTPS_CERT`. Caddy terminates TLS on this box, and
  a second certificate owner is a renewal argument nobody wins.
- Do not configure GoCardless, SimpleFIN or any other bank aggregator. Each is a signup
  somewhere else, with its own credentials, and it is the user's decision.
- Do not enable OpenID login. One server password is the design here.
- Do not enable end-to-end encryption on the user's behalf. Losing that key loses the budget,
  and the choice belongs to whoever will have to remember it.
No terminal agent? Use the chat fallback — slower, you paste the commands

For ChatGPT or Claude in a browser. The model cannot touch your server, so it hands you one command at a time and you run each one. Same install, more of your evening.

This path is slower: you paste every command yourself, and there is nobody watching the output
but you. If you can run Claude Code, use the other tab.

You are installing Actual Budget 26.8.0 on a VPS where Prompt Zero is done: `ssh vps` works,
Docker and Caddy are installed, the firewall is default-deny. Run everything over `ssh vps`
unless a step says otherwise, and replace `<DOMAIN>` with the hostname whose A record already
points at the box.

## 1. Preflight

```bash
free -m | awk '/^Mem:/ {print $7 " MB available of " $2 " MB"}'
df -BG --output=avail /srv | tail -1
dpkg --print-architecture
dig +short <DOMAIN>
```

You should see: at least `512` MB available, at least `5` G free, `amd64` or `arm64`, and your
server's IP address on the last line.

If you do not: an empty last line means the A record does not exist yet. Add it at your DNS
provider, wait a minute, and run `dig +short <DOMAIN>` again. Caddy cannot get a certificate
for a hostname that does not resolve, and failed attempts count against a rate limit you cannot
see.

## 2. Layout

The image creates an `actual` account with uid 1001 and runs as it, so `data` belongs to 1001
and not to you.

```bash
sudo install -d -m 750 -o $(id -u) -g $(id -g) /srv/actual-budget /srv/actual-budget/backups
sudo install -d -m 750 -o 1001 -g 1001 /srv/actual-budget/data
ls -la /srv/actual-budget
```

You should see: `backups` owned by your own username, and `data` owned by `1001`.

If you do not: `data` owned by you means the second command did not run, and the container will
fail to write account.sqlite with a permission error that mentions nothing about ownership.
Run the second line again on its own.

## 3. Secrets

There is nothing to generate and no `.env` file in this install. Actual has exactly one
credential, the server password, and you choose it in a browser at step 7.

Two things to know before you pick it. That one password is the whole door: it guards every
budget file on this server. And end-to-end encryption is a separate setting inside Actual, per
budget file, off by default, so until you turn it on your budget on this disk is readable by
anyone who can read the disk. If you do turn it on, losing that key loses the budget, and
nobody can reset it for you.

Nothing in this guide asks you to paste a credential into this chat window. Do not, at any
point, paste the server password or the output of any command that contains it.

## 4. compose.yml

Paste the whole block at once, including the last two lines.

```bash
cat > /srv/actual-budget/compose.yml <<'EOF'
# Actual Budget · the deterministic fallback. Authored by caniselfhostit from
# the upstream documentation, not copied from a repository:
#   image, port, /data .. https://actualbudget.org/docs/install/docker
#   configuration ....... https://actualbudget.org/docs/config/
#   health route ........ https://github.com/actualbudget/actual/blob/master/packages/sync-server/src/scripts/health-check.js
#
# One container, no database process and no secret to generate: the sync server
# keeps account.sqlite and the budget blobs under /data, and the only credential
# is the server password you set in a browser at step 7. The image runs as uid
# 1001, hence the ownership in step 2. Tag and digest are the 26.8.0 release read
# from Docker Hub on 2026-08-05, for linux/amd64 and linux/arm64.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  actual:
    image: actualbudget/actual-server:26.8.0@sha256:0b300f370dba85a74998a953736a831bd931cc8cb76c0d8ceac3d3fd288dfd4d
    container_name: actual
    restart: unless-stopped
    environment:
      # Caddy reaches the published port from the host, so the container sees
      # the Docker bridge as the client. Naming that range keeps the rate
      # limiter counting real clients instead of one proxy.
      ACTUAL_TRUSTED_PROXIES: 172.16.0.0/12
    volumes:
      # server-files holds account.sqlite, user-files holds the budget blobs.
      # Local disk only: SQLite needs real POSIX file locks to stay intact.
      - /srv/actual-budget/data:/data
    ports:
      # Loopback only. The Caddy that Prompt Zero installed on the host is the
      # only thing that can reach this port, and 8090 never enters the firewall.
      - "127.0.0.1:8090:5006"
EOF
cd /srv/actual-budget && docker compose config >/dev/null && echo "compose OK"
```

You should see: `compose OK` and nothing else.

If you do not: `services must be a mapping` means the indentation was lost between the page and
your terminal. Run `rm /srv/actual-budget/compose.yml` and paste the block again in one go.

Upstream's own example publishes port 5006 on every interface. That is convenient on a laptop
and wrong on a machine with a public IP, which is why this one binds to 127.0.0.1.

## 5. Caddy and TLS

This appends one site block to the Caddy config Prompt Zero installed. Replace `<DOMAIN>` in
the block with your hostname before you paste. The first line takes a copy, because a syntax
error here takes down every other site on the box.

```bash
sudo cp /etc/caddy/Caddyfile /etc/caddy/Caddyfile.before-actual-budget
printf '\n' | sudo tee -a /etc/caddy/Caddyfile >/dev/null
sudo tee -a /etc/caddy/Caddyfile >/dev/null <<'EOF'
# Actual Budget · the Caddy site block for this service.
#
# Authored by caniselfhostit from https://caddyserver.com/docs/automatic-https
# and https://actualbudget.org/docs/install/docker
#
# Append this to /etc/caddy/Caddyfile, with <DOMAIN> replaced by the hostname
# pointed at this box. Caddy runs under systemd. No Caddy container here.

<DOMAIN> {
	encode zstd gzip

	header {
		Strict-Transport-Security "max-age=31536000; includeSubDomains"
		X-Content-Type-Options "nosniff"
		X-Frame-Options "SAMEORIGIN"
		Referrer-Policy "no-referrer"
		-Server
	}

	# 8090 is the loopback port compose publishes; it is never in the firewall.
	# A full budget upload arrives as one request, so no body limit is set here
	# and ACTUAL_UPLOAD_FILE_SYNC_SIZE_LIMIT_MB stays at the upstream default.
	reverse_proxy 127.0.0.1:8090
}
EOF
sudo caddy validate --config /etc/caddy/Caddyfile
sudo systemctl reload caddy
```

You should see: `Valid configuration` from validate, and no output at all from reload.

If you do not: run `sudo cp /etc/caddy/Caddyfile.before-actual-budget /etc/caddy/Caddyfile`,
reload, and paste again, checking that the blank line from the second command really landed.
Caddy asks Let's Encrypt for the certificate on the first request to your hostname and renews
it on its own. Actual can serve HTTPS itself if you hand it a key and certificate; do not, on
this box. Two certificate owners is a renewal argument nobody wins.

## 6. Firewall

```bash
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw allow 443/udp
sudo ufw status verbose
```

You should see: `Status: active`, rules for `80/tcp`, `443/tcp` and `443/udp`, and no rule
mentioning `8090`.

If you do not: a rule for `8090` from an earlier attempt should go, with
`sudo ufw delete allow 8090`. 8090 is bound to 127.0.0.1 by the compose file, so nothing
outside the machine can reach it and a firewall rule for it would cover traffic that cannot
arrive.

## 7. Start and verify

```bash
cd /srv/actual-budget
docker compose pull
docker compose up -d
sleep 15
curl -sS https://<DOMAIN>/health
echo
curl -sS https://<DOMAIN>/account/needs-bootstrap
echo
```

You should see: a line of JSON containing `"status":"UP"`, then a line of JSON containing
`"bootstrapped":false`.

If you do not: `000` or `502` means the certificate is not there yet, so run
`sudo journalctl -u caddy -n 30`. Nothing at all from `/health` means the container did not
start: run `docker compose logs --tail 30 actual` and look for a permission error on `/data`,
which is step 2 done wrong.

A container listed in `docker ps` is not proof of anything. The two lines of JSON are.

`"bootstrapped":false` means this server is open to whoever loads the page first. Open
https://<DOMAIN> now, choose the server password, and save it in your password manager before
you do anything else. Then check the flip:

```bash
curl -sS https://<DOMAIN>/account/needs-bootstrap
echo
```

You should see: `"bootstrapped":true`.

If you do not: the password was not saved. Go back to the browser and finish. Nothing on this
server is yours until that says true.

## 8. First backup and restore

Do this before you import a single transaction, so you find out now whether it works. The stop
matters: a SQLite file copied mid-write is not a backup.

```bash
cd /srv/actual-budget
docker compose stop
sudo tar -C /srv/actual-budget -czf /srv/actual-budget/backups/actual-budget-$(date +%F).tar.gz data
docker compose start
ls -lh /srv/actual-budget/backups/
```

You should see: one `.tar.gz` file, tens of kilobytes on a fresh install.

If you do not: `tar: data: Cannot open` means the `cd` did not happen. A size of `45` bytes
means tar wrote an empty archive because the paths were wrong, so check
`sudo ls /srv/actual-budget/data` before you trust it.

A backup on the same disk as the data is not a backup. Run this one on your own machine, not on
the server:

```bash
mkdir -p ~/backups/actual-budget
scp vps:/srv/actual-budget/backups/*.tar.gz ~/backups/actual-budget/
```

You should see: one file copied, and the same file listed by `ls -lh ~/backups/actual-budget/`.

If you do not: `Permission denied (publickey)` means you ran it on the server by mistake. The
`vps:` prefix only means something on your own machine.

Now prove the restore, because a backup you have never restored is a guess:

```bash
cd /srv/actual-budget
docker compose down
sudo rm -rf /srv/actual-budget/data
sudo tar -C /srv/actual-budget -xzf /srv/actual-budget/backups/actual-budget-$(date +%F).tar.gz
docker compose up -d
sleep 15
curl -sS https://<DOMAIN>/account/needs-bootstrap
echo
```

You should see: `Created`, `Started`, then `"bootstrapped":true` again, and the same server
password still works in the browser.

If you do not: `"bootstrapped":false` after a restore means the archive did not contain
`server-files/account.sqlite`. Stop and go back to the tar step. Those four commands are the
whole disaster plan, and you have now run them once.

One more thing worth doing tonight: inside Actual, export a zip of your budget and keep it
somewhere else. It is readable without a server, which is more than any archive on this box can
say.

## 9. Updating later

New versions are at https://github.com/actualbudget/actual/releases. Take a backup first, then
edit the `image:` line in /srv/actual-budget/compose.yml to the new tag and its digest.

```bash
cd /srv/actual-budget
docker compose pull
docker compose up -d
docker compose logs --tail 20 actual
```

You should see: `Recreated`, then a few startup lines and no repeating restart.

If you do not: put the old tag and digest back and run the same three commands. Your browser
caches the app, so after an upgrade load the page and hard-refresh once before deciding
anything is broken.

## 10. What will probably go wrong

Nothing during the install, and then you will ask where your bank is. Actual does not connect
to banks by itself: it talks to GoCardless or SimpleFIN, each a separate signup with its own
credentials, and in the United States the usable one is not free. I finished this install in
under ten minutes and then spent an hour discovering that the part I actually wanted was a
different product with a different bill. Decide how you feel about that before you move a
year of budget across, not after.

## 11. Out of scope

- Do not set `ACTUAL_HTTPS_KEY` or `ACTUAL_HTTPS_CERT`. Caddy terminates TLS on this box.
- Do not configure GoCardless, SimpleFIN or any other bank aggregator yet. Each is a signup
  somewhere else with its own credentials, and it is a decision, not a step.
- Do not enable OpenID login. One server password is the design here.
- Do not turn on end-to-end encryption until you have somewhere safe for the key. Losing it
  loses the budget, and nobody can reset it for you.

agent-readable mirror: /self-host/ynab.md

The files, if you'd rather do it yourself

The same install with no agent involved: three files, in the order you'd use them. The prompt above writes exactly these — if the two ever disagree, the files are the ones CI diffs.

compose.ymlthe services, pinned32 lines

authored from upstream docs, never pasted · 1,736 bytes

# Actual Budget · the deterministic fallback. Authored by caniselfhostit from
# the upstream documentation, not copied from a repository:
#   image, port, /data .. https://actualbudget.org/docs/install/docker
#   configuration ....... https://actualbudget.org/docs/config/
#   health route ........ https://github.com/actualbudget/actual/blob/master/packages/sync-server/src/scripts/health-check.js
#
# One container, no database process and no secret to generate: the sync server
# keeps account.sqlite and the budget blobs under /data, and the only credential
# is the server password you set in a browser at step 7. The image runs as uid
# 1001, hence the ownership in step 2. Tag and digest are the 26.8.0 release read
# from Docker Hub on 2026-08-05, for linux/amd64 and linux/arm64.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  actual:
    image: actualbudget/actual-server:26.8.0@sha256:0b300f370dba85a74998a953736a831bd931cc8cb76c0d8ceac3d3fd288dfd4d
    container_name: actual
    restart: unless-stopped
    environment:
      # Caddy reaches the published port from the host, so the container sees
      # the Docker bridge as the client. Naming that range keeps the rate
      # limiter counting real clients instead of one proxy.
      ACTUAL_TRUSTED_PROXIES: 172.16.0.0/12
    volumes:
      # server-files holds account.sqlite, user-files holds the budget blobs.
      # Local disk only: SQLite needs real POSIX file locks to stay intact.
      - /srv/actual-budget/data:/data
    ports:
      # Loopback only. The Caddy that Prompt Zero installed on the host is the
      # only thing that can reach this port, and 8090 never enters the firewall.
      - "127.0.0.1:8090:5006"
Caddyfilethe hostname and TLS24 lines

authored from upstream docs, never pasted · 825 bytes

# Actual Budget · the Caddy site block for this service.
#
# Authored by caniselfhostit from https://caddyserver.com/docs/automatic-https
# and https://actualbudget.org/docs/install/docker
#
# Append this to /etc/caddy/Caddyfile, with <DOMAIN> replaced by the hostname
# pointed at this box. Caddy runs under systemd. No Caddy container here.

<DOMAIN> {
	encode zstd gzip

	header {
		Strict-Transport-Security "max-age=31536000; includeSubDomains"
		X-Content-Type-Options "nosniff"
		X-Frame-Options "SAMEORIGIN"
		Referrer-Policy "no-referrer"
		-Server
	}

	# 8090 is the loopback port compose publishes; it is never in the firewall.
	# A full budget upload arrives as one request, so no body limit is set here
	# and ACTUAL_UPLOAD_FILE_SYNC_SIZE_LIMIT_MB stays at the upstream default.
	reverse_proxy 127.0.0.1:8090
}
install.shthe same install, no agent130 lines

authored from upstream docs, never pasted · 5,518 bytes

#!/usr/bin/env bash
# Actual Budget · the agent-free install.
#
# Everything prompt.md tells an agent to do, as a script you can read first.
# Run it on the VPS, as a non-root user who is in the docker group:
#
#   DOMAIN_HOST=budget.example.com ./install.sh
#
# Authored by caniselfhostit from the upstream documentation:
#   https://actualbudget.org/docs/install/docker
#   https://actualbudget.org/docs/config/
#   https://caddyserver.com/docs/automatic-https
#
# Nothing is generated here. Actual has one credential, the server password, and
# you choose it in a browser at step 7. There is no .env file in this install.
#
# NOT YET VERIFIED: no harness run has been recorded against this script.
set -euo pipefail

APP_DIR="${APP_DIR:-/srv/actual-budget}"
DOMAIN_HOST="${DOMAIN_HOST:-}"

die() { printf 'install.sh: %s\n' "$1" >&2; exit 1; }

# --- 1. Refuse to start on a machine that is not ready -----------------------

[ -n "$DOMAIN_HOST" ] || die "set DOMAIN_HOST to the hostname you pointed at this server, e.g. budget.example.com"
command -v docker >/dev/null 2>&1 || die "docker is not installed. Run Prompt Zero first."
docker compose version >/dev/null 2>&1 || die "the docker compose plugin is missing"
command -v caddy >/dev/null 2>&1 || die "caddy is not installed on the host. Run Prompt Zero first."

avail_mb="$(free -m | awk '/^Mem:/ {print $7}')"
[ "$avail_mb" -ge 512 ] || die "only ${avail_mb} MB of RAM available; this install wants 512 MB"
avail_gb="$(df -BG --output=avail /srv | tail -1 | tr -dc '0-9')"
[ "$avail_gb" -ge 5 ] || die "only ${avail_gb} GB free on /srv; this install wants 5 GB"

resolved="$(getent hosts "$DOMAIN_HOST" | awk '{print $1; exit}' || true)"
[ -n "$resolved" ] || die "$DOMAIN_HOST does not resolve yet. Add the A record, wait a minute, run this again."

# --- 2. Lay the files out ----------------------------------------------------
#
# The image creates an actual account with uid 1001 and runs as it.

sudo install -d -m 750 -o "$(id -u)" -g "$(id -g)" "$APP_DIR" "$APP_DIR/backups"
sudo install -d -m 750 -o 1001 -g 1001 "$APP_DIR/data"
install -m 0644 "$(dirname "$0")/compose.yml" "$APP_DIR/compose.yml"
install -m 0644 "$(dirname "$0")/Caddyfile" "$APP_DIR/Caddyfile"

cd "$APP_DIR"
docker compose config >/dev/null

# --- 3. Caddy site block, on the host ----------------------------------------

if ! sudo grep -qF "$DOMAIN_HOST {" /etc/caddy/Caddyfile; then
	sudo cp /etc/caddy/Caddyfile "/etc/caddy/Caddyfile.before-actual-budget"
	printf '\n' | sudo tee -a /etc/caddy/Caddyfile >/dev/null
	sed "s|<DOMAIN>|${DOMAIN_HOST}|g" "$APP_DIR/Caddyfile" | sudo tee -a /etc/caddy/Caddyfile >/dev/null
fi
sudo caddy validate --config /etc/caddy/Caddyfile
sudo systemctl reload caddy

# --- 4. Ports: two open, and 8090 is not one of them -------------------------

if command -v ufw >/dev/null 2>&1; then
	echo "==> 80/tcp and 443/tcp for Caddy, 443/udp for HTTP/3; 8090 stays closed"
	sudo ufw allow 80/tcp
	sudo ufw allow 443/tcp
	sudo ufw allow 443/udp
	sudo ufw status verbose
fi

# --- 5. Start it and prove it works ------------------------------------------

docker compose pull
docker compose up -d

echo "==> waiting for https://${DOMAIN_HOST}/health (Caddy is getting a certificate)"
for _ in $(seq 1 30); do
	code="$(curl -sS -o /dev/null -w '%{http_code}' "https://${DOMAIN_HOST}/health" || true)"
	[ "$code" = "200" ] && break
	sleep 5
done
[ "${code:-}" = "200" ] || die "/health answered ${code:-nothing}. Check: docker compose logs --tail 30 actual"

curl -sS "https://${DOMAIN_HOST}/health" | grep -q 'UP' \
	|| die "/health answered 200 but did not report UP. Check: docker compose logs --tail 30 actual"

curl -sS "https://${DOMAIN_HOST}/account/needs-bootstrap" | grep -q '"bootstrapped":false' \
	|| echo "==> this server already has a password set; skipping the bootstrap prompt"

# --- 6. Set the one credential this install has ------------------------------

cat <<-BOOTSTRAP

	Open https://${DOMAIN_HOST} now and choose the server password. Until you do,
	whoever loads that page first gets to choose it instead, and that one
	password guards every budget file on this server.

BOOTSTRAP
printf 'Press Return once you have set it. '
read -r _

curl -sS "https://${DOMAIN_HOST}/account/needs-bootstrap" | grep -q '"bootstrapped":true' \
	|| die "the server still reports bootstrapped:false. Set the password before going on."

# --- 7. The first backup, before day one ends --------------------------------
#
# Stopped, then copied. A SQLite file captured mid-write is not a backup.

docker compose stop
sudo tar -C "$APP_DIR" -czf "$APP_DIR/backups/actual-budget-$(date +%Y%m%d-%H%M%S).tar.gz" data
docker compose start
ls -lh "$APP_DIR/backups/"

cat <<-DONE

	Actual Budget is running at https://${DOMAIN_HOST}/

	  1. data/ is the whole install. server-files/account.sqlite holds the
	     password hash, user-files holds the budgets. There is no .env here.
	  2. Actual does not talk to banks by itself. GoCardless and SimpleFIN are
	     separate signups with their own credentials, and in the United States
	     the usable one costs money. Find that out now, not after you migrate.
	  3. Export a zip of your budget from inside the app once a month and keep
	     it somewhere else. It is readable without a server, which is more than
	     any archive on this box can say.
	  4. First backup written to $APP_DIR/backups. It is on the same disk as
	     the data, which is not a backup. Copy it somewhere else tonight.

DONE

What you're signing up for

The part a vendor's comparison page leaves out. None of it is a reason not to do this; all of it is yours the moment you cancel YNAB.

  • Bank sync is not included and is not free everywhere. Actual reaches banks through GoCardless or SimpleFIN, each a separate signup with its own credentials, and in the United States the usable one costs money. YNAB's connections are in the subscription; this trade is the main one you are making.
  • One password guards the whole server. There is nothing else to log in with, no per-user accounts by default, and no password reset email, because this install sends no mail at all. Put it in a password manager the minute you choose it.
  • End-to-end encryption is off by default and is set per budget file. Turn it on and the data on the disk is opaque even to you; lose that key and the budget is gone, because nobody can reset it. Leave it off and anyone who can read the disk can read the budget.
  • You own the backups. Everything is /srv/actual-budget/data, copied with the container stopped. Actual also exports a plain zip of any budget from inside the interface, and a monthly one of those kept elsewhere is worth more than any archive on the box, because it opens without a server.
  • The server is for syncing, not for running the app. The desktop build works with no server at all; this container exists so a phone and a second browser see the same budget. If only one machine ever touches it, you did not need this.

Where this came from

“'/data' is the path Actual will look for its files in by default, so leave that as-is.”

  • ACTUAL_DATA_DIR defaults to /data, with server-files holding account.sqlite and session data and user-files holding the budget files as binary blobs. source
  • The published image creates a non-root actual account with uid 1001, gives it /data, and exposes port 5006. source
  • The container's health check calls /health and expects a JSON status of UP. source
  • /account/needs-bootstrap is an unauthenticated endpoint that reports whether the server password has been set yet, and /bootstrap can only be called once. source

Questions people actually ask

Answered from this page's own data — the same numbers, in sentences.

  • Can I self-host YNAB?

    Not YNAB itself — the vendor does not ship a version you can run on your own server. What you can self-host is the job people pay it for, and the answer to that is Actual Budget. Zero-based envelope budgeting in one container, with the budget file on your disk and no subscription attached to your money. The install is one command: one container behind Caddy with automatic TLS, secrets generated on the server rather than in a chat window, and a first backup taken before the agent says it is done, in about 10 minutes. The prompt on this page does it; the compose.yml, Caddyfile and install.sh below do the same install with no agent at all.

  • What replaces YNAB?

    Actual Budget. Zero-based envelope budgeting in one container, with the budget file on your disk and no subscription attached to your money. It is a direct descendant of the same envelope method, down to the rule that every dollar gets assigned before it can be spent, and it keeps the whole budget in a local-first file that syncs through a server you run. The gap is bank sync: YNAB's connections are included, and Actual reaches banks only through a third-party aggregator you set up and, in most regions, pay for separately. Actual Budget is MIT-licensed and free; nothing on this page is a hosted service we sell you.

  • What does self-hosting cost compared to YNAB?

    512 MB of RAM and 5 GB of disk — the smallest tier most VPS hosts sell, about $5 a month. Actual Budget itself is free and MIT-licensed; the bill is the server, plus a domain you probably already own. What you stop paying: YNAB Monthly, $14.99/mo — $179.88 a year.

  • How hard is it really?

    ONE COMMAND — under 10 minutes. The rule that produced that verdict: one container, no database, no outside integration, at most one secret. Nothing to negotiate with anyone else, nothing to back up separately, at most one secret to generate. This is the case where the compose file honestly is the whole install. The tier is derived from seven countable facts about the Actual Budget install, not from anyone's impression of it, and the whole rubric is published on the methodology page.

Content last checked 2026-08-05. Verdicts are derived from the published rubric on /methodology; corrections go through the issue tracker.