# Can I self-host YNAB?

**YES** — it's called Actual Budget. ONE COMMAND setup · ~10 minutes to running · 512 MB RAM minimum · $14.99/mo you stop paying ($179.88/yr on the Monthly plan).

Actual Budget authored from upstream docs · not yet machine-verified · source: https://caniselfhostit.com/self-host/ynab/

## Install prompt (Claude Code)

````text
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.
````

## Chat fallback

````text
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.
````

## docker-compose.yml

```yaml
# 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"
```

## Caddyfile

```text
# 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.sh

```bash
#!/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
```

The page this mirrors: https://caniselfhostit.com/self-host/ynab/ · How the verdict, the timings and the prices are derived: https://caniselfhostit.com/methodology/ · Source, data and corrections: https://github.com/caniselfhostit/caniselfhostit
