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.

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 on the cloud path, and the local path needs none
Time budget
~10 minunder 10 minutes, through the first backup

The prompt

Two paths to the same Actual Budget: the cloud one assumes Prompt Zero is done on a server you rent, the local one assumes nothing but a computer that can run Docker Desktop. Read whichever you pick before you paste it, which is the whole reason both are on the page instead of behind a download.

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

Where it runs

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.

316 lines · 14,966 bytes

What this prompt will do
  1. Preflight
  2. Docker
  3. Layout
  4. Secrets
  5. compose.yml
  6. Nothing is public
  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 this computer · installs Docker Desktop if it is missing · no server, no domain

You are Claude Code on the user's own computer. There is no server and no Prompt Zero:
everything in this prompt runs on this machine and stays on it.

Run every command on this computer, in the shell you are already in. Nothing in this prompt
uses ssh.

Install Actual Budget 26.8.0 on this computer, reachable at http://localhost:8090 from
this machine and nowhere else, with everything it owns under ~/selfhost/actual-budget.

## 1. Preflight

Find out which computer this is. Every later step branches on this answer.

```bash
uname -s
case "$(uname -s)" in
  Darwin) sysctl -n hw.memsize ;;
  Linux) free -m ;;
  MINGW*|MSYS*) powershell -Command "(Get-CimInstance Win32_ComputerSystem).TotalPhysicalMemory" ;;
esac
df -h ~
```

`uname -s` prints `Darwin` for macOS, `Linux` for Linux, or a string starting `MINGW` or
`MSYS` for Windows under Git Bash.

Actual Budget needs 512 MB of RAM available and 5 GB free on the filesystem holding the
home directory. It runs on amd64 and arm64, so Apple Silicon is covered. On Linux read
the `available` column of `free -m`. On Darwin and Windows the command prints installed
memory in bytes, where 512 MB is 536870912: that is a floor, not the real ceiling, since
the container gets only what Docker Desktop's VM was given, under Settings, Resources.

If RAM is under 512 MB or free space is under 5 GB, print both numbers and stop. Do not
install and hope.

Say this to the user before anything is installed: their budget is about to live in one
folder on this computer and nowhere else, so the backup in step 8 is the whole safety net.

## 2. Docker

Check before installing anything:

```bash
docker info >/dev/null 2>&1 && echo "docker OK" || echo "docker MISSING"
docker compose version 2>/dev/null || true
```

If that printed `docker OK` and a compose version, skip to step 3.

Otherwise, install Docker for the OS step 1 detected:

- macOS: if `command -v brew` succeeds, run `brew install --cask docker`. If there is no
  Homebrew, STOP: tell the user to download Docker Desktop from
  https://www.docker.com/products/docker-desktop/ and install it, and wait until they
  confirm. Either way, then STOP: tell the user to open Docker Desktop once, accept its
  terms, and wait for the whale icon to say it is running. Do not continue until they
  confirm.
- Windows: run `winget install -e --id Docker.DockerDesktop`. If winget is missing or the
  install fails, STOP: tell the user to download Docker Desktop from the URL above and
  install it, and wait until they confirm. Docker Desktop configures WSL 2 itself and may
  ask for a reboot; if it does, STOP and tell the user to reboot and come back, this
  prompt resumes at this step. Then STOP: have the user open Docker Desktop, accept its
  terms, and confirm it says running.
- Linux, Debian or Ubuntu: install Docker Engine from download.docker.com's apt
  repository, with its signing key saved to a file first, never piped into a shell.
- Linux, anything else: STOP. Tell the user to install Docker Engine and the compose
  plugin with their distribution's package manager, and to run this prompt again once
  `docker info` works.

The Debian and Ubuntu branch in full, a no-op on any other system:

```bash
if [ "$(uname -s)" = "Linux" ] && command -v apt-get >/dev/null 2>&1; then
  sudo apt-get update
  sudo apt-get install -y ca-certificates curl
  sudo install -m 0755 -d /etc/apt/keyrings
  sudo curl -fsSL https://download.docker.com/linux/$(. /etc/os-release && echo "$ID")/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/$(. /etc/os-release && echo "$ID") $(. /etc/os-release && echo "$VERSION_CODENAME") stable" | sudo tee /etc/apt/sources.list.d/docker.list >/dev/null
  sudo apt-get update
  sudo apt-get install -y docker-ce docker-ce-cli containerd.io docker-compose-plugin
  sudo usermod -aG docker "$USER"
fi
```

Tell the user in one sentence that docker group membership is root-equivalent on this
machine, and that the change lands at their next login, so they log out and back in
before continuing.

Assert: `docker info` exits 0 and `docker compose version` prints a version. Do not
continue without both.

## 3. Layout

```bash
mkdir -p ~/selfhost/actual-budget/data ~/selfhost/actual-budget/backups
ls -la ~/selfhost/actual-budget
```

The image creates an `actual` account with uid 1001 and runs as it, so on Linux `data` has
to belong to 1001 or the container cannot write account.sqlite. On Linux only, run:

```bash
sudo chown -R 1001:1001 ~/selfhost/actual-budget/data
```

On macOS and Windows do not run that: Docker Desktop's file sharing maps ownership between
its VM and the host, so the container writes as uid 1001 inside while the files stay the
user's own outside.

Assert: `ls -la` lists `data` and `backups`. Nothing in this install is written outside
~/selfhost/actual-budget.

## 4. Secrets

Nothing to generate here, and there is no `.env` file. Actual has exactly one credential,
the server password, and the user chooses it in a browser at step 7.

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

## 5. compose.yml

```bash
cat > ~/selfhost/actual-budget/compose.yml <<'EOF'
# Actual Budget · the deterministic fallback for the local path. 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
#
# Every path here is relative to ~/selfhost/actual-budget/, which lets one file
# work on macOS, Linux and Windows. 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, which matters on Linux and
# which Docker Desktop handles elsewhere. 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:
      # Carried over so this file matches the server file line for line.
      # Nothing proxies to this container here and nothing sets an
      # X-Forwarded-For header, so on this path the setting does nothing.
      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.
      # Relative to this file, so the whole install is one folder to copy.
      - ./data:/data
    ports:
      # Loopback only. Nothing outside this computer reaches 8090, including
      # the phone on the same wifi. Step 6 says why that is the point.
      - "127.0.0.1:8090:5006"
EOF
cd ~/selfhost/actual-budget && docker compose config >/dev/null && echo "compose OK"
```

Assert: that prints `compose OK`. The container listens on 5006 inside and 8090 is bound
to 127.0.0.1 out here. Upstream's own example publishes 5006 on every
interface, which would put the budget on whatever wifi this computer joins next; this file
does not.

## 6. Nothing is public

Nothing in this install is reachable from anywhere but this computer, and that is the
shape of this path, not a gap in it.

- `127.0.0.1:8090` binds the port to the loopback interface. The router, the coffee-shop
  network, and the user's own phone on the same wifi all get nothing.
- DNS, certificates and firewall rules do not apply here: no hostname to resolve, nothing
  to certify, no open port for a rule to cover.
- Browsers treat `http://localhost` as a secure context, so the Web Crypto behind Actual's
  end-to-end encryption works over plain HTTP here. Nothing is missing for want of TLS.
- Say the trade out loud: a sync server exists so a phone and a second browser see the
  same budget, and on this path the phone cannot reach it. The browser on this computer is
  the only client this install will ever have.
- The real boundary is the user's own account on this machine: anyone who can log in as
  them can read ~/selfhost/actual-budget, and on Windows its mode bits are advisory, so
  the account password is the protection that counts.

## 7. Start and verify

```bash
cd ~/selfhost/actual-budget
docker compose pull
docker compose up -d
sleep 15
curl -sS -o /dev/null -w '%{http_code}\n' http://localhost:8090
curl -sS http://localhost:8090/health
echo
curl -sS http://localhost:8090/account/needs-bootstrap
echo
```

If `docker compose up -d` exits non-zero, do not run the curls: print its error. If it
names port 8090 as already allocated, something else on this computer holds it. Name that
with `lsof -nP -iTCP:8090 -sTCP:LISTEN` on macOS and Linux, or
`netstat -ano | findstr :8090` under Git Bash, tell the user, and stop rather than moving
to another port.

Assert, all three: the first prints `200`, `/health` prints JSON containing
`"status":"UP"`, and `/account/needs-bootstrap` prints JSON containing
`"bootstrapped":false`. Print exactly what you received for each. If any of the three
misses, stop, run `docker compose logs --tail 30 actual`, and name the likely earlier
step. On Linux a permission error mentioning /data is step 3 run without the chown. On
Windows a SQLite `database is locked` error means the folder is on the Windows filesystem
share, which has no real file locks: rerun this from a WSL 2 shell. A running container is
not success.

Actual has no base URL to configure: the page is served from the same origin it syncs to,
so http://localhost:8090 is the whole address, and what the user types if they later point
Actual's desktop build at this server.

The first screen at http://localhost:8090 asks the user to choose a password for this
server.

STOP: tell the user to open http://localhost:8090 now, set that password, and save it in
their password manager. Wait. Do not continue until they confirm.

```bash
curl -sS http://localhost:8090/account/needs-bootstrap
echo
```

Assert: this now prints `"bootstrapped":true`. Until it does, the password was not set,
and nothing else here matters yet.

## 8. First backup and restore

Take the backup now, before the user imports a single transaction. Stop the container
first: a SQLite file copied mid-write is not a backup. On Linux the files under `data`
belong to uid 1001 from step 3, so run both `tar` lines here, and the two restore lines
below, with `sudo`. Elsewhere they are the user's own files and plain tar reads them.

```bash
cd ~/selfhost/actual-budget
ARCHIVE=backups/actual-budget-$(date +%F).tar.gz
docker compose stop
tar -czf "$ARCHIVE" data
docker compose start
tar -tzf "$ARCHIVE" | grep server-files/account.sqlite
ls -lh "$ARCHIVE"
case "$(uname -s)" in Darwin) open . ;; Linux) xdg-open . ;; MINGW*|MSYS*) pwd -W; explorer.exe . ;; esac || true
```

Assert: the tar exited 0 and the listing printed `data/server-files/account.sqlite`. A tar
that failed part-way leaves a file too, so existence is not proof. Print its size. `data`
is the whole install: no `.env` here, that sqlite file holds the password hash,
`data/user-files` holds the budgets.

That archive sits on the same disk as the data, and on one computer the disk and the
machine fail together, so it is not yet a backup. The last command opened
~/selfhost/actual-budget in Finder or Explorer, and printed its `C:/` path under Git Bash.
Tell the user to copy the archive out of `backups` to somewhere that leaves this computer:
a folder a sync service already watches, or a USB stick. Wait for them to confirm.

To restore: `docker compose down`, then `rm -rf data`, then `tar -xzf` the newest archive
in `backups/`, then `docker compose up -d`. Those four commands are the whole disaster
plan, and this archive is what makes them work.

Tell the user: Actual exports a plain zip of any budget from inside the interface, and a
monthly one kept elsewhere is worth more than any archive here, because it opens without a
server.

## 9. Updating later

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

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

Actual migrates its own database on the next boot, and the browser caches the app, so load
http://localhost:8090 and hard-refresh once before calling the update done.

## 10. What will probably go wrong

The computer reboots and the budget looks deleted. `restart: unless-stopped` only means
Docker starts the container once Docker itself is up, and on macOS and Windows Docker
Desktop does not start itself unless somebody ticked that box. I rebooted, opened
http://localhost:8090 out of habit, got a page saying the site could not be reached, and
spent a minute genuinely believing a year of budget had gone. It had not: the container
was stopped, not deleted, and the folder untouched. Start Docker Desktop, run
`docker compose ps` in ~/selfhost/actual-budget, and if the container is not running, run
`docker compose up -d`. Tell the user to turn on Docker Desktop's
start-at-login setting, so this happens once.

## 11. Out of scope

- Do not expose this to the internet. The whole point of this path is one machine holding
  the money; reaching it from outside is the server path, a different prompt.
- Do not configure port forwarding on the router. A budget behind one password on a home
  router with no certificate is the worst of both paths.
- Do not add a reverse proxy or TLS. There is no hostname to certify, and
  `http://localhost` is already a secure context.
- Do not configure GoCardless, SimpleFIN or any other bank aggregator. Each is a separate
  signup with its own credentials, in the United States the usable one costs money, and
  the decision is the user's to make before moving a year of budget across.
- 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.
compose.local.ymlthe services, pinned · local layout35 lines

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

# Actual Budget · the deterministic fallback for the local path. 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
#
# Every path here is relative to ~/selfhost/actual-budget/, which lets one file
# work on macOS, Linux and Windows. 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, which matters on Linux and
# which Docker Desktop handles elsewhere. 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:
      # Carried over so this file matches the server file line for line.
      # Nothing proxies to this container here and nothing sets an
      # X-Forwarded-For header, so on this path the setting does nothing.
      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.
      # Relative to this file, so the whole install is one folder to copy.
      - ./data:/data
    ports:
      # Loopback only. Nothing outside this computer reaches 8090, including
      # the phone on the same wifi. Step 6 says why that is the point.
      - "127.0.0.1:8090:5006"

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

The files, if you'd rather do it yourself

The cloud path with no agent involved: three files, in the order you'd use them. The cloud prompt above writes exactly these — if the two ever disagree, the files are the ones CI diffs. The local path ships its own compose file, collapsed under its own prompt.

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.

  • Can I run Actual Budget on my own computer instead of a server?

    Yes — that is the second path in the prompt box above. "On my computer" installs the same Actual Budget on the machine you are sitting at: no VPS, no domain, no DNS, and nothing exposed to the internet. It checks for Docker first and installs Docker Desktop if the machine does not have it — macOS, Windows and Linux each get their own step — then binds everything to loopback, so the app answers on http://localhost and only on that computer. Worth knowing: The budget never leaves this computer, which also means nothing else is holding a copy of it, so the backup step is the whole safety net. Same discipline as the cloud path: pinned images, secrets generated on the machine, and a first backup taken before the prompt says it is done.

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