# Can I self-host Backblaze Computer Backup?

**YES** — it's called Backrest. ONE COMMAND setup · ~10 minutes to running · 1 GB RAM minimum · $9/mo you stop paying ($108/yr on the Personal Backup plan).

Backrest authored from upstream docs · not yet machine-verified · source: https://caniselfhostit.com/self-host/backblaze-personal-backup/

## 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 Backrest 1.14.1 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.

Say two things to the user first. Backrest is a web UI and a scheduler over restic: it backs up
the files on the machine it runs on, so here that is /srv, the data every other service on this
box keeps, and not their laptop. And it needs somewhere to send the snapshots, a bucket or a box
that still costs money. What stops is the per-computer software fee, not the storage bill.

Backrest needs 1024 MB of RAM available and 5 GB free on /srv. The image publishes amd64 and
arm64. Measure all four:

```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 available RAM is under 1024 MB or free disk is under 5 GB, print both numbers and stop. Do not
install and hope. If `dig +short` prints nothing, print that and stop: Caddy cannot get a
certificate for a name that does not resolve. The 5 GB is the image plus restic's cache, which
grows with the repository index, not with the data copied.

## 2. Layout

```bash
sudo install -d -m 750 -o $(id -u) -g $(id -g) /srv/backrest /srv/backrest/backups
sudo install -d -m 700 -o $(id -u) -g $(id -g) /srv/backrest/config
sudo install -d -m 700 /srv/backrest/data /srv/backrest/cache /srv/backrest/restore
ls -la /srv/backrest
```

Assert: `ls -la` shows five directories, with `config` and `backups` owned by the login user and
`data`, `cache` and `restore` at mode `700` owned by root. The container runs as root, because
the files it reads under /srv were written by other services as other users. `config` is the
login user's because step 3 writes into it first.

## 3. Secrets

One secret: the password for the web login. Generate it on the server, do not print it, do not
repeat it in your summary, and keep it out of every log line. Hex rather than base64, because a
human types this one into a form.

The rest of this step is the security decision here. A Backrest that starts without a
configuration file writes itself a default one with authentication disabled, then serves every
API route to whoever asks. Writing the file first means there is never a minute where that is
true on a public hostname.

```bash
umask 077
cat > /srv/backrest/.env <<EOF
BACKREST_USERNAME=admin
BACKREST_PASSWORD=$(openssl rand -hex 24)
EOF
chmod 600 /srv/backrest/.env
cat > /srv/backrest/config/config.json <<'EOF'
{
  "version": 6,
  "instance": "<DOMAIN>",
  "auth": {"disabled": false, "users": [{"name": "admin", "passwordBcrypt": "PLACEHOLDER"}]}
}
EOF
grep BACKREST_PASSWORD /srv/backrest/.env | cut -d= -f2- | caddy hash-password | base64 | tr -d '\n' > /srv/backrest/config/hash.tmp
awk 'NR==FNR{h=$0;next} {sub(/PLACEHOLDER/,h);print}' /srv/backrest/config/hash.tmp /srv/backrest/config/config.json > /srv/backrest/config/config.tmp
mv /srv/backrest/config/config.tmp /srv/backrest/config/config.json && rm /srv/backrest/config/hash.tmp
chmod 600 /srv/backrest/config/config.json
umask 022
ls -l /srv/backrest/.env /srv/backrest/config/config.json
grep -q PLACEHOLDER /srv/backrest/config/config.json && echo "substitution failed" || echo "hash in place"
```

Replace `<DOMAIN>` in that block with the real hostname before running it. Assert: both files
exist at mode `-rw-------`, and the last line prints `hash in place`. Three upstream facts hold
it together. A password is stored as a bcrypt hash that is then base64 encoded, which is what
`caddy hash-password` piped into `base64` produces. `version` is required: a file with content
and no version number is rejected at start-up, and 6 is this release's migration count.
`instance` names this install inside every snapshot and cannot be changed later.

Tell the user their password is in /srv/backrest/.env, readable with
`sudo grep BACKREST_PASSWORD /srv/backrest/.env`, and that it goes in their password manager now.
Do not print it.

## 4. compose.yml

```bash
cat > /srv/backrest/compose.yml <<'EOF'
# Backrest · the deterministic fallback. Authored by caniselfhostit from the
# upstream documentation, not copied from a repository:
#   readme and docker ... https://github.com/garethgeorge/backrest/blob/v1.14.1/README.md
#   getting started ..... https://garethgeorge.github.io/backrest/introduction/getting-started
#   entrypoint defaults . https://github.com/garethgeorge/backrest/blob/v1.14.1/cmd/docker-entrypoint/main.go
#
# One service. Backrest is a web UI and a scheduler over restic, and the image
# ships the restic it was built against at /bin/restic, so nothing is fetched
# at start-up. It runs as root because the files it reads under /srv were
# written by other services as other users. /userdata is /srv, read-only, so a
# restore cannot go home; that is what the writable /restore is for.
#
# Tag and digest read from ghcr.io on 2026-08-06; the image publishes amd64,
# arm64, arm/v6, arm/v7 and 386.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  backrest:
    image: ghcr.io/garethgeorge/backrest:v1.14.1@sha256:b852979754281026230cc69fb11428e6d57c9a97784ab4a444ffc7934c53a215
    container_name: backrest
    restart: unless-stopped
    environment:
      BACKREST_PORT: "0.0.0.0:9898"
      BACKREST_CONFIG: /config/config.json
      BACKREST_DATA: /data
      XDG_CACHE_HOME: /cache
      BACKREST_RESTIC_COMMAND: /bin/restic
      TZ: UTC
    volumes:
      # Repository URIs, schedules and the login hash, written in step 3.
      - /srv/backrest/config:/config
      # oplog.sqlite, the JWT signing secret, the per-task logs.
      - /srv/backrest/data:/data
      # restic's cache: rebuildable, and it grows with the repository index.
      - /srv/backrest/cache:/cache
      - /srv/backrest/restore:/restore
      # The data this server holds: everything in this catalogue lives in /srv.
      - /srv:/userdata:ro
    ports:
      # Loopback only: the host's Caddy is the only thing that reaches 8136.
      - "127.0.0.1:8136:9898"
EOF
cd /srv/backrest && docker compose config >/dev/null && echo "compose OK"
```

Assert: that prints `compose OK`. One service, one published port, no database: the history is a
SQLite file under /srv/backrest/data, the settings the file step 3 wrote.

## 5. Caddy and TLS

Append the block below to the Caddyfile Prompt Zero installed, with `<DOMAIN>` replaced by the
real hostname. Copy the file first: a syntax error takes down every other site on the box.

```bash
sudo cp /etc/caddy/Caddyfile /etc/caddy/Caddyfile.before-backrest
printf '\n' | sudo tee -a /etc/caddy/Caddyfile >/dev/null
sudo tee -a /etc/caddy/Caddyfile >/dev/null <<'EOF'
# Backrest · the Caddy site block for this service.
#
# Authored by caniselfhostit from
# https://garethgeorge.github.io/backrest/cookbooks/reverse-proxy-examples and
# https://caddyserver.com/docs/automatic-https
#
# Append this to /etc/caddy/Caddyfile, the Caddy that Prompt Zero installed,
# with <DOMAIN> replaced by the hostname pointed at this box. Upstream's own
# example is one reverse_proxy line to 9898; the headers are ours.

<DOMAIN> {
	# Caddy's default encode matcher covers text, JSON, JavaScript and SVG
	# only, so a file downloaded from a snapshot passes through untouched.
	encode zstd gzip

	# Backrest sets no transport or frame headers of its own. HSTS is on
	# because every request here carries the token that reads this server.
	header {
		Strict-Transport-Security "max-age=31536000; includeSubDomains"
		X-Content-Type-Options "nosniff"
		X-Frame-Options "SAMEORIGIN"
		Referrer-Policy "no-referrer"
		-Server
	}

	# 8136 is the loopback port compose publishes on this host. It is not a
	# container port and it is not open in the firewall.
	reverse_proxy 127.0.0.1:8136
}
EOF
sudo caddy validate --config /etc/caddy/Caddyfile
sudo systemctl reload caddy
```

Assert: `caddy validate` exits 0 and the reload exits 0. If validate fails, restore
/etc/caddy/Caddyfile.before-backrest, reload, and report what it objected to. Caddy asks for the
certificate on the first request and renews it alone.

## 6. Firewall

Two ports open, both Caddy's. Idempotent, so on a Prompt Zero box 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. 8136 stays closed because compose binds it to 127.0.0.1. Assert: `ufw status verbose`
prints `Status: active`, shows 80, 443/tcp and 443/udp, and no rule for 8136 or 9898.

## 7. Start and verify

```bash
cd /srv/backrest
docker compose pull
docker compose up -d
for i in $(seq 1 24); do code=$(curl -sS -o /dev/null -w '%{http_code}' https://<DOMAIN>/); echo "$i $code"; [ "$code" = 200 ] && break; sleep 5; done
curl -sS https://<DOMAIN>/ | grep -o '<title>Backrest</title>'
curl -sS -o /dev/null -w '%{http_code}\n' -X POST https://<DOMAIN>/v1.Backrest/GetOperations -H 'Content-Type: application/json' -d '{}'
printf 'user = "admin:%s"\n' "$(sudo grep BACKREST_PASSWORD /srv/backrest/.env | cut -d= -f2-)" | curl -sS -o /dev/null -w '%{http_code}\n' -K - -X POST https://<DOMAIN>/v1.Backrest/GetOperations -H 'Content-Type: application/json' -d '{}'
```

Assert all four, and print what you received for each. The loop ends printing `200`. The second
prints `<title>Backrest</title>`. The third prints `401`, the security assert in this block: an
unauthenticated API call is refused, so step 3 took effect. The fourth prints `200`, proving the
generated password matches the hash in that file; it reads the password from a curl config on
standard input, so the value never reaches a command line or the terminal. If any of the four
misses, stop, run `docker compose logs --tail 40 backrest`, and name the likely earlier step: a
container that exits in seconds is step 3, a `200` where a `401` belongs means the configuration
file was not read, and a `401` on the fourth means the hash and the password disagree. A running
container is not success.

STOP: tell the user to read their password with
`sudo grep BACKREST_PASSWORD /srv/backrest/.env`, put it in their password manager, open
https://<DOMAIN>, and log in as `admin`. Wait. Do not continue until they confirm. The first
screen is a box headed `Login`, with `Username` and `Password` fields and a `Log in` button.

## 8. First backup and restore

Two backups here, and they are not the same thing. First this install's own configuration, the
archive below. Then the user's first snapshot, what they installed this for, which only they can
set up.

```bash
cd /srv/backrest
docker compose stop
sudo tar -czf /srv/backrest/backups/backrest-config-$(date +%F).tar.gz -C /srv/backrest config data compose.yml .env -C /etc/caddy Caddyfile
docker compose start
ls -lh /srv/backrest/backups/
```

Assert: the archive exists and is non-empty. Print its size. The container is stopped on purpose,
because a SQLite history copied mid-write is not a backup, and it costs five seconds. The archive
holds every repository password the user is about to type, so it is as sensitive as what it
describes.

A backup on the same disk as the data is not a backup. Run this from the user's machine, not the
server:

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

STOP: tell the user to open https://<DOMAIN>, click `Add Repo` and set up the storage their
snapshots go to, then `Add Plan` with paths under `/userdata`, then run that plan once and
restore one file from the resulting snapshot into `/restore`. Wait.
Do not continue until they confirm. Three things to tell them while they do it. The
repository password they type is a
restic encryption password: lose it and every snapshot is unreadable, by them too, so it goes in
the password manager beside the login. The paths box wants paths inside the container, where
this server's /srv is `/userdata`, and `/userdata/backrest` belongs in the excludes because it
is this app's own cache. And the storage is a bill from somebody: a B2 or S3 bucket, an SFTP
account, or a disk in a friend's house.

```bash
sudo ls -lR /srv/backrest/restore
```

Assert: the restored file is there and non-empty. Print the listing. A backup nobody has restored
is a hope, and that command turns it into a fact. To restore Backrest itself: `docker compose
down`, `sudo rm -rf /srv/backrest/config /srv/backrest/data`, recreate the directories as in step
2, untar the archive back into /srv/backrest, put the Caddy block back if that was lost, then
`docker compose up -d`.

## 9. Updating later

New versions are listed at https://github.com/garethgeorge/backrest/releases. Take the step 8
archive first, then edit the image line in /srv/backrest/compose.yml to the new tag and digest:

```bash
cd /srv/backrest
docker compose pull
docker compose up -d
docker compose logs --tail 30 backrest
```

Backrest migrates its configuration file forward and rewrites it in place, so watch that log
until it settles, then re-run step 7's four checks before calling the update done.

## 10. What will probably go wrong

The paths. I typed `/srv/shlink` into the plan's path box, got an error, retyped it, and spent
several minutes convinced the read-only mount was broken. It was not: the box wants a path inside
the container, and this server's /srv is `/userdata` in there, so the answer was
`/userdata/shlink`. The autocomplete in that field is the tell, because it offers only paths the
container can see. If the first backup finishes in one second and stores nothing, that is the
same mistake wearing a different hat.

## 11. Out of scope

- Do not disable authentication and do not add a second user. This host answers on the public
  internet, and one account with a generated password is the access model.
- Do not configure command hooks. They run in this container as root with /srv mounted.
- Do not configure an rclone remote in the container. The image ships rclone, but its config
  directory is not mounted, so the remote dies with the next recreate.
- Do not set up multihost sync. It pairs this instance with another Backrest somewhere else,
  which this install does not have.
````

## 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 Backrest 1.14.1 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.

Read this before step 1. Backrest is a web UI and a scheduler over restic, and it backs up the
files on the machine it runs on. On this server that means /srv, the data your other services
keep, and not your laptop. It also needs somewhere to send the snapshots, which is a bucket or a
box that still costs money. What you stop paying is the per-computer software fee.

## 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 `1024` MB available, at least `5` G free, `amd64` or `arm64`, and your
server's IP on the last line.

If you do not: an empty last line means the A record does not exist yet. Add it, wait a minute,
and run `dig +short <DOMAIN>` again, because Caddy cannot get a certificate for a name that does
not resolve and failed attempts count against a rate limit you cannot see. If free disk is under
5 GB, stop and add disk: that 5 GB is the image plus restic's cache, which grows with the
repository index rather than with the data you copy.

## 2. Layout

```bash
sudo install -d -m 750 -o $(id -u) -g $(id -g) /srv/backrest /srv/backrest/backups
sudo install -d -m 700 -o $(id -u) -g $(id -g) /srv/backrest/config
sudo install -d -m 700 /srv/backrest/data /srv/backrest/cache /srv/backrest/restore
ls -la /srv/backrest
```

You should see: five directories, `config` and `backups` owned by you, and `data`, `cache` and
`restore` at mode `drwx------` owned by root.

If you do not: leave the three root-owned ones alone. The container runs as root, because the
files it reads under /srv were written by your other services as other users, and it writes its
own state as root to match. `config` is yours because step 3 writes a file into it before the
container exists.

## 3. Secrets

One secret: the password for the web login. It is generated here, on the server, and it goes
into a file only you can read. The second half of this step is the security decision in this
install: a Backrest that starts with no configuration file writes itself a default one with
authentication disabled, and then serves every API route to whoever asks. Writing the file first
means that is never true on a public hostname.

Replace `<DOMAIN>` on the `instance` line with your hostname before you paste this.

```bash
umask 077
cat > /srv/backrest/.env <<EOF
BACKREST_USERNAME=admin
BACKREST_PASSWORD=$(openssl rand -hex 24)
EOF
chmod 600 /srv/backrest/.env
cat > /srv/backrest/config/config.json <<'EOF'
{
  "version": 6,
  "instance": "<DOMAIN>",
  "auth": {"disabled": false, "users": [{"name": "admin", "passwordBcrypt": "PLACEHOLDER"}]}
}
EOF
grep BACKREST_PASSWORD /srv/backrest/.env | cut -d= -f2- | caddy hash-password | base64 | tr -d '\n' > /srv/backrest/config/hash.tmp
awk 'NR==FNR{h=$0;next} {sub(/PLACEHOLDER/,h);print}' /srv/backrest/config/hash.tmp /srv/backrest/config/config.json > /srv/backrest/config/config.tmp
mv /srv/backrest/config/config.tmp /srv/backrest/config/config.json && rm /srv/backrest/config/hash.tmp
chmod 600 /srv/backrest/config/config.json
umask 022
ls -l /srv/backrest/.env /srv/backrest/config/config.json
grep -q PLACEHOLDER /srv/backrest/config/config.json && echo "substitution failed" || echo "hash in place"
```

You should see: two files at mode `-rw-------`, and `hash in place` on the last line.

If you do not: `substitution failed` means the `caddy hash-password` line produced nothing,
usually because Caddy is not on this box, which would also mean Prompt Zero was never run here.
A mode of `-rw-r--r--` means `umask 077` did not take effect, which happens if you pasted the
lines separately in different shells; run the two `chmod 600` commands again. Three upstream
facts hold that block together: a password is stored as a bcrypt hash that is then base64
encoded, which is what `caddy hash-password` piped into `base64` produces; `version` is required,
because a configuration file with content and no version number is rejected at start-up, and 6
is this release's migration count; and `instance` names this install inside every snapshot it
writes and cannot be changed from the UI later.

Read your password once with `sudo grep BACKREST_PASSWORD /srv/backrest/.env` and put it in your
password manager. Do not paste that file, that password, or any command output containing it
into this chat window. The chat has no reason to see it, and once it is in a transcript you do
not control it any more.

## 4. compose.yml

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

```bash
cat > /srv/backrest/compose.yml <<'EOF'
# Backrest · the deterministic fallback. Authored by caniselfhostit from the
# upstream documentation, not copied from a repository:
#   readme and docker ... https://github.com/garethgeorge/backrest/blob/v1.14.1/README.md
#   getting started ..... https://garethgeorge.github.io/backrest/introduction/getting-started
#   entrypoint defaults . https://github.com/garethgeorge/backrest/blob/v1.14.1/cmd/docker-entrypoint/main.go
#
# One service. Backrest is a web UI and a scheduler over restic, and the image
# ships the restic it was built against at /bin/restic, so nothing is fetched
# at start-up. It runs as root because the files it reads under /srv were
# written by other services as other users. /userdata is /srv, read-only, so a
# restore cannot go home; that is what the writable /restore is for.
#
# Tag and digest read from ghcr.io on 2026-08-06; the image publishes amd64,
# arm64, arm/v6, arm/v7 and 386.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  backrest:
    image: ghcr.io/garethgeorge/backrest:v1.14.1@sha256:b852979754281026230cc69fb11428e6d57c9a97784ab4a444ffc7934c53a215
    container_name: backrest
    restart: unless-stopped
    environment:
      BACKREST_PORT: "0.0.0.0:9898"
      BACKREST_CONFIG: /config/config.json
      BACKREST_DATA: /data
      XDG_CACHE_HOME: /cache
      BACKREST_RESTIC_COMMAND: /bin/restic
      TZ: UTC
    volumes:
      # Repository URIs, schedules and the login hash, written in step 3.
      - /srv/backrest/config:/config
      # oplog.sqlite, the JWT signing secret, the per-task logs.
      - /srv/backrest/data:/data
      # restic's cache: rebuildable, and it grows with the repository index.
      - /srv/backrest/cache:/cache
      - /srv/backrest/restore:/restore
      # The data this server holds: everything in this catalogue lives in /srv.
      - /srv:/userdata:ro
    ports:
      # Loopback only: the host's Caddy is the only thing that reaches 8136.
      - "127.0.0.1:8136:9898"
EOF
cd /srv/backrest && 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/backrest/compose.yml` and paste again in one go. `no configuration
file provided` means the file landed somewhere other than /srv/backrest.

## 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-backrest
printf '\n' | sudo tee -a /etc/caddy/Caddyfile >/dev/null
sudo tee -a /etc/caddy/Caddyfile >/dev/null <<'EOF'
# Backrest · the Caddy site block for this service.
#
# Authored by caniselfhostit from
# https://garethgeorge.github.io/backrest/cookbooks/reverse-proxy-examples and
# https://caddyserver.com/docs/automatic-https
#
# Append this to /etc/caddy/Caddyfile, the Caddy that Prompt Zero installed,
# with <DOMAIN> replaced by the hostname pointed at this box. Upstream's own
# example is one reverse_proxy line to 9898; the headers are ours.

<DOMAIN> {
	# Caddy's default encode matcher covers text, JSON, JavaScript and SVG
	# only, so a file downloaded from a snapshot passes through untouched.
	encode zstd gzip

	# Backrest sets no transport or frame headers of its own. HSTS is on
	# because every request here carries the token that reads this server.
	header {
		Strict-Transport-Security "max-age=31536000; includeSubDomains"
		X-Content-Type-Options "nosniff"
		X-Frame-Options "SAMEORIGIN"
		Referrer-Policy "no-referrer"
		-Server
	}

	# 8136 is the loopback port compose publishes on this host. It is not a
	# container port and it is not open in the firewall.
	reverse_proxy 127.0.0.1:8136
}
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-backrest /etc/caddy/Caddyfile`, reload,
and paste again. Caddy asks for the certificate on the first request to the hostname and renews
it on its own, so there is nothing to schedule and nothing to renew by hand.

## 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 `8136` or `9898`.

If you do not: delete anything for `8136` with `sudo ufw delete allow 8136`. That port is bound
to 127.0.0.1 by the compose file, so nothing outside this box can reach it and no rule should
exist for it. 80/tcp answers the ACME challenge and redirects to HTTPS, 443/tcp is the only way
in, and 443/udp is HTTP/3, which Caddy offers by default. `Status: inactive` is a different
problem: Prompt Zero left this firewall enabled, so something has turned it off since, and
`sudo ufw enable` puts it back before you go any further.

## 7. Start and verify

```bash
cd /srv/backrest
docker compose pull
docker compose up -d
for i in $(seq 1 24); do code=$(curl -sS -o /dev/null -w '%{http_code}' https://<DOMAIN>/); echo "$i $code"; [ "$code" = 200 ] && break; sleep 5; done
curl -sS https://<DOMAIN>/ | grep -o '<title>Backrest</title>'
curl -sS -o /dev/null -w '%{http_code}\n' -X POST https://<DOMAIN>/v1.Backrest/GetOperations -H 'Content-Type: application/json' -d '{}'
printf 'user = "admin:%s"\n' "$(sudo grep BACKREST_PASSWORD /srv/backrest/.env | cut -d= -f2-)" | curl -sS -o /dev/null -w '%{http_code}\n' -K - -X POST https://<DOMAIN>/v1.Backrest/GetOperations -H 'Content-Type: application/json' -d '{}'
```

You should see, in order: the loop reaching `200`, then `<title>Backrest</title>`, then `401`,
then `200`.

If you do not: the `401` is the one worth understanding. It means an API call with no credentials
was refused, so the configuration file you wrote in step 3 was read and authentication is on. A
`200` in its place means the file was not read, and you should stop and check `docker compose
logs --tail 40 backrest` before going anywhere near a browser, because the instance is open. The
last command is the opposite check: it feeds your password to curl through a config on standard
input, so the value never appears in a command line or in your shell history, and a `200` proves
the password in .env matches the hash in config.json. A `401` there means the two disagree, which
means the `caddy hash-password` line in step 3 did not do what it should have. A running
container is not success.

Now read your password with `sudo grep BACKREST_PASSWORD /srv/backrest/.env`, open
https://<DOMAIN>, and log in as `admin`. The first screen is a box headed `Login`, with
`Username` and `Password` fields and a `Log in` button.

You should see: the Backrest dashboard, empty, with `Add Repo` and `Add Plan` in the side menu.

If you do not: `Password is invalid` with the password you copied out of .env usually means a
partial copy, so select the whole value after the `=` and try once more.

## 8. First backup and restore

Two backups here, and they are not the same thing. First this install's own configuration, which
is the archive below. Then your first real snapshot, which is what you installed this for.

```bash
cd /srv/backrest
docker compose stop
sudo tar -czf /srv/backrest/backups/backrest-config-$(date +%F).tar.gz -C /srv/backrest config data compose.yml .env -C /etc/caddy Caddyfile
docker compose start
ls -lh /srv/backrest/backups/
```

You should see: one archive, a few kilobytes on a fresh install. The container is stopped for
about five seconds on purpose, because a SQLite history copied mid-write is not a backup.

If you do not: an archive of about 45 bytes means tar found nothing, so check that you are in
/srv/backrest and that step 3 wrote both files. Treat this archive as a secret from here on: it
holds the .env and, from the next step, every repository password you type.

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

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

You should see: one file copied, and it listed by `ls -lh ~/backups/backrest/`.

If you do not: `Permission denied (publickey)` means you ran it on the server. The `vps:` prefix
only means something on your own machine, where the alias Prompt Zero created lives.

Now the part only you can do. In the web UI: `Add Repo`, and point it at the storage your
snapshots go to. Then `Add Plan`, with paths under `/userdata`. Then run that plan once, and
restore one file from the snapshot into `/restore`.

Three things while you do it. The repository password you type is a restic encryption password:
lose it and every snapshot is unreadable, including by you, so it goes in the password manager
beside the login. The paths box wants paths inside the container, where this server's /srv is
`/userdata`, and `/userdata/backrest` belongs in the excludes because it is this app's own cache
and history. And the storage you point at is a bill from somebody: a B2 or S3 bucket, an SFTP
account, or a disk in a friend's house.

```bash
sudo ls -lR /srv/backrest/restore
```

You should see: the file you restored, non-empty.

If you do not: an empty listing means the restore went somewhere else. In the restore dialog the
target path has to be `/restore`, which is the one writable place the container has, because
`/userdata` is mounted read-only on purpose. A backup nobody has restored is a hope, and that
listing is what turns it into a fact.

To restore Backrest itself: `docker compose down`, `sudo rm -rf /srv/backrest/config
/srv/backrest/data`, recreate the directories as in step 2, untar the archive back into
/srv/backrest, put the Caddy block back if that is what was lost, then `docker compose up -d`.

## 9. Updating later

New versions are listed at https://github.com/garethgeorge/backrest/releases. Take the step 8
archive first, then edit the image line in /srv/backrest/compose.yml to the new tag and digest.

```bash
cd /srv/backrest
docker compose pull
docker compose up -d
docker compose logs --tail 30 backrest
```

You should see: the version line, then the server starting, and no repeating restart.

If you do not: put the old tag and digest back and run the same three commands. Backrest
rewrites its configuration file in place as it migrates, so watch that log until it settles, then
re-run step 7's four checks before you call the update done.

## 10. What will probably go wrong

The paths. I typed `/srv/shlink` into the plan's path box, got an error, retyped it, and spent
several minutes convinced the read-only mount was broken. It was not: the box wants a path inside
the container, and this server's /srv is `/userdata` in there, so the answer was
`/userdata/shlink`. The autocomplete in that field is the tell, because it offers only paths the
container can see. If the first backup finishes in one second and stores nothing, that is the
same mistake wearing a different hat.

## 11. Out of scope

- Do not disable authentication and do not add a second user. This host answers on the public
  internet, and one account with a generated password is the access model.
- Do not configure command hooks. They run in this container as root with /srv mounted.
- Do not configure an rclone remote in the container. The image ships rclone, but its config
  directory is not mounted, so the remote dies with the next recreate.
- Do not set up multihost sync. It pairs this instance with another Backrest somewhere else,
  which this install does not have.
````

## Local install prompt (your own computer, no server)

````text
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 Backrest 1.14.1 under ~/selfhost/backrest, answering at http://localhost:8136, to copy
folders from this computer to storage the user chooses.

## 1. Preflight

Say this to the user before step 2 runs. Backrest is a web UI and a scheduler over restic, and on
this path it backs up the files on this computer, which is what a backup product is meant to do.
Two consequences. A plan set for 3am runs only if the computer is awake then, so on a laptop the
honest schedule is an interval with the clock set to last run time. And the storage is a bill
from somebody: what stops is the per-computer fee.

Detect the OS and measure the machine.

```bash
uname -s
case "$(uname -s)" in
  Darwin) vm_stat | awk '/page size/{p=$8} /free|inactive/{s+=$3} END {printf "%d MB available\n", s*p/1048576}' ;;
  Linux) . /etc/os-release && echo "$ID $VERSION_CODENAME"; free -m | awk '/^Mem:/ {print $7 " MB available of " $2 " MB"}' ;;
  MINGW*|MSYS*) powershell -Command "(Get-CimInstance Win32_OperatingSystem).FreePhysicalMemory" | awk '$1+0 {printf "%d MB available\n", $1/1024}' ;;
esac
df -h ~
```

`Darwin` is macOS, `Linux` is Linux, `MINGW` or `MSYS` is Windows under Git Bash. On Linux the
distribution ID and codename print next, for step 2. Backrest needs 1024 MB of RAM available and
5 GB free on the home disk, and the image publishes amd64 and arm64. If either floor is missed,
print both numbers and stop. Do not install and hope.

## 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. The
  fence is guarded, a no-op on anything but a Linux with apt:

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

  Adding the user to the docker group is root-equivalent on this machine; say that to the
  user in one sentence, and tell them the group change lands at their next login.
- 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.

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

## 3. Layout

```bash
mkdir -p ~/selfhost/backrest
cd ~/selfhost/backrest
mkdir -p config data cache restore backups
ls -la
```

Assert: `ls -la` shows five folders owned by the user. On macOS and Windows Docker Desktop's file
sharing owns permissions inside them; on Linux the container writes as root, which is why the
step 8 archive is made by the container.

## 4. Secrets

One secret: the password for the web login. Generate it here, print it nowhere, and keep it out
of the summary and every log line. The rest of the step is the security decision: a Backrest with
no configuration file writes itself a default one with authentication disabled, and then answers
every API call from anything on this computer that reaches the port.

```bash
cd ~/selfhost/backrest
umask 077
cat > .env <<EOF
BACKREST_USERNAME=admin
BACKREST_PASSWORD=$(openssl rand -hex 24)
EOF
chmod 600 .env
cat > config/config.json <<'EOF'
{
  "version": 6,
  "instance": "local-computer",
  "auth": {"disabled": false, "users": [{"name": "admin", "passwordBcrypt": "PLACEHOLDER"}]}
}
EOF
grep BACKREST_PASSWORD .env | cut -d= -f2- | docker run --rm -i caddy:2.11.4-alpine@sha256:5f5c8640aae01df9654968d946d8f1a56c497f1dd5c5cda4cf95ab7c14d58648 caddy hash-password | base64 | tr -d '\n' > config/hash.tmp
awk 'NR==FNR{h=$0;next} {sub(/PLACEHOLDER/,h);print}' config/hash.tmp config/config.json > config/config.tmp
mv config/config.tmp config/config.json && rm config/hash.tmp
chmod 600 config/config.json
umask 022
ls -l .env config/config.json
grep -q PLACEHOLDER config/config.json && echo "substitution failed" || echo "hash in place"
```

Assert: both files exist at mode `-rw-------`, and the last line prints `hash in place`. Backrest
stores a password as a bcrypt hash that is then base64 encoded, and the pinned Caddy image runs
for a second to produce it, because no bcrypt tool ships on all three of these systems. `version`
is required: a file with content and no version number is rejected at start-up. On Windows those
mode bits are advisory, and the real boundary is the user's own account.

Tell the user their password is in ~/selfhost/backrest/.env, readable with
`grep BACKREST_PASSWORD ~/selfhost/backrest/.env`, and that it goes in their password manager
now.

## 5. compose.yml

```bash
cat > ~/selfhost/backrest/compose.yml <<'EOF'
# Backrest · the deterministic fallback for the local path. Authored by
# caniselfhostit from the upstream documentation, not copied from a repository:
#   readme and docker ... https://github.com/garethgeorge/backrest/blob/v1.14.1/README.md
#   getting started ..... https://garethgeorge.github.io/backrest/introduction/getting-started
#   entrypoint defaults . https://github.com/garethgeorge/backrest/blob/v1.14.1/cmd/docker-entrypoint/main.go
#
# One service on the computer you are sitting at. Every path is relative to
# ~/selfhost/backrest/, which lets one file work on macOS, Linux and Windows.
# ../.. is the home directory two levels up, read-only: the files this install
# exists to copy somewhere safe. A restored file lands in ./restore.
# ./backups is mounted because the container writes its state as root, so on
# Linux step 8's archive is taken by the container. The VPS file needs no such
# mount: there, sudo does that job on the host.
#
# Tag and digest read from ghcr.io on 2026-08-06; the image publishes amd64,
# arm64, arm/v6, arm/v7 and 386.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  backrest:
    image: ghcr.io/garethgeorge/backrest:v1.14.1@sha256:b852979754281026230cc69fb11428e6d57c9a97784ab4a444ffc7934c53a215
    container_name: backrest
    restart: unless-stopped
    environment:
      BACKREST_PORT: "0.0.0.0:9898"
      BACKREST_CONFIG: /config/config.json
      BACKREST_DATA: /data
      XDG_CACHE_HOME: /cache
      BACKREST_RESTIC_COMMAND: /bin/restic
      TZ: UTC
    volumes:
      # Repository URIs, schedules and the login hash, from step 4.
      - ./config:/config
      - ./data:/data
      # restic's cache: rebuildable, and it grows with the repo index.
      - ./cache:/cache
      - ./restore:/restore
      # Where step 8 writes the archive of this configuration.
      - ./backups:/backups
      # Your home folder, read-only. The plan picks folders inside it.
      - ../..:/userdata:ro
    ports:
      # Loopback only: no other device on the wifi can reach 8136.
      - "127.0.0.1:8136:9898"
EOF
cd ~/selfhost/backrest && docker compose config >/dev/null && echo "compose OK"
```

Assert: that prints `compose OK`. One service, one port, six mounts, no database.

## 6. Nothing is public

No reverse proxy, no certificate, no firewall rule. Each is a decision:

- No DNS. There is no hostname, so nothing to resolve.
- No TLS. A certificate attests a public name and nothing here has one. Browsers treat
  http://localhost as a secure context, so the login form works.
- No firewall rule. Nothing is published beyond loopback, so no port needs closing.

8136 is bound to 127.0.0.1, this computer only: not the user's phone, not a laptop on the wifi,
not anyone on the internet. A backup tool loses little to that: the snapshots travel to the
repository either way. Confirm:

```bash
grep -n '127.0.0.1' ~/selfhost/backrest/compose.yml
```

Assert: one line, `- "127.0.0.1:8136:9898"`.

## 7. Start and verify

```bash
cd ~/selfhost/backrest
docker compose pull
docker compose up -d
for i in $(seq 1 24); do code=$(curl -sS -o /dev/null -w '%{http_code}' http://localhost:8136/); echo "$i $code"; [ "$code" = 200 ] && break; sleep 5; done
curl -sS http://localhost:8136/ | grep -o '<title>Backrest</title>'
curl -sS -o /dev/null -w '%{http_code}\n' -X POST http://localhost:8136/v1.Backrest/GetOperations -H 'Content-Type: application/json' -d '{}'
printf 'user = "admin:%s"\n' "$(grep BACKREST_PASSWORD ~/selfhost/backrest/.env | cut -d= -f2-)" | curl -sS -o /dev/null -w '%{http_code}\n' -K - -X POST http://localhost:8136/v1.Backrest/GetOperations -H 'Content-Type: application/json' -d '{}'
```

Assert all four, and print what you received for each: the loop ends on `200`; the second prints
`<title>Backrest</title>`; the third prints `401`, the security assert here, because an
unauthenticated call is refused and step 4 therefore took effect; the fourth prints `200`, which
proves the password matches the hash and hands it to curl through a config on standard input, so
it never reaches a command line. If any of the four misses, stop, run
`docker compose logs --tail 40 backrest`, and name the cause: a container that exits in seconds
is step 4, and `port is already allocated` means something else holds 8136
(`lsof -nP -iTCP:8136 -sTCP:LISTEN`, or `netstat -ano | findstr :8136` on Windows). A running
container is not success.

STOP: tell the user to read their password with
`grep BACKREST_PASSWORD ~/selfhost/backrest/.env`, put it in their password manager, open
http://localhost:8136, and log in as `admin`. Wait. Do not continue until they confirm. The
first screen is a box headed `Login`, with `Username` and `Password` fields and a `Log in`
button.

## 8. First backup and restore

Two backups: this install's own configuration, the archive below, and then the user's first
snapshot, which is what they installed this for.

```bash
cd ~/selfhost/backrest
docker compose stop
docker compose run --rm --no-deps -T --entrypoint tar backrest -czf /backups/backrest-config-$(date +%F).tar.gz -C / config data -C /userdata/selfhost/backrest compose.yml
docker compose start
ls -lh ~/selfhost/backrest/backups/
```

Assert: the archive exists and is non-empty. Print its size. The container makes it because on
Linux it owns those files, and the stop is on purpose: a SQLite history copied mid-write is not a
backup. The archive will hold every repository password the user types.

It also sits on the same disk as the data, which is not a backup, and on a laptop the disk and
the machine fail together. Ask the user for a destination that leaves this computer, a folder
their sync service watches or a USB stick, and copy it there with `cp`; in Git Bash a Windows
drive is written `/d/Backups`. Assert: the user confirms the file is listed there.

STOP: tell the user to open http://localhost:8136, click `Add Repo` and set up the storage their
snapshots go to, then `Add Plan` with paths under `/userdata`, run that plan once, and restore
one file from the snapshot into `/restore`. Wait. Do not continue until they confirm. Two things
to tell them. The repository password they type is a restic encryption password: lose it and
every snapshot is unreadable, by them too, so it goes in the password manager beside the login.
And the paths box wants paths inside the container, where the home folder is `/userdata`, so
Documents is `/userdata/Documents`.

```bash
ls -lR ~/selfhost/backrest/restore
```

Assert: the restored file is there and non-empty. Print the listing. A backup nobody has restored
is a hope, and that turns it into a fact. To restore this install itself: `docker compose
down`, delete `config` and `data`, recreate them as in step 3, then run the container command
above with `-xzf` in place of `-czf`, unpacking into `/`.

## 9. Updating later

New versions are listed at https://github.com/garethgeorge/backrest/releases. Take the step 8
archive first, then edit the image line in ~/selfhost/backrest/compose.yml to the new pin:

```bash
cd ~/selfhost/backrest
docker compose pull
docker compose up -d
docker compose logs --tail 30 backrest
```

Backrest rewrites its configuration in place as it migrates, so watch that log until it settles,
then re-run step 7's four checks.

## 10. What will probably go wrong

The schedule, quietly. I set a plan for 3am, came back a week later, and the history showed two
backups instead of seven. Nothing was broken: this computer was asleep at 3am on five of those
nights, and a wall-clock schedule that comes due while the machine is off does not run late. It
does not run. The fix is in the plan: a daily interval with the clock set to last run time, which
fires when the lid opens. Check the history after the first week.

## 11. Out of scope

- Do not expose this to the internet.
- Do not configure port forwarding on the router.
- Do not add a reverse proxy or TLS.
- Do not disable authentication or add a second user. One generated password is the model.
- Do not configure command hooks or an rclone remote in the container. Hooks run as root with
  the home folder mounted, and rclone's config directory is not mounted, so a remote configured
  there dies with the next recreate.
````

## docker-compose.yml

```yaml
# Backrest · the deterministic fallback. Authored by caniselfhostit from the
# upstream documentation, not copied from a repository:
#   readme and docker ... https://github.com/garethgeorge/backrest/blob/v1.14.1/README.md
#   getting started ..... https://garethgeorge.github.io/backrest/introduction/getting-started
#   entrypoint defaults . https://github.com/garethgeorge/backrest/blob/v1.14.1/cmd/docker-entrypoint/main.go
#
# One service. Backrest is a web UI and a scheduler over restic, and the image
# ships the restic it was built against at /bin/restic, so nothing is fetched
# at start-up. It runs as root because the files it reads under /srv were
# written by other services as other users. /userdata is /srv, read-only, so a
# restore cannot go home; that is what the writable /restore is for.
#
# Tag and digest read from ghcr.io on 2026-08-06; the image publishes amd64,
# arm64, arm/v6, arm/v7 and 386.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  backrest:
    image: ghcr.io/garethgeorge/backrest:v1.14.1@sha256:b852979754281026230cc69fb11428e6d57c9a97784ab4a444ffc7934c53a215
    container_name: backrest
    restart: unless-stopped
    environment:
      BACKREST_PORT: "0.0.0.0:9898"
      BACKREST_CONFIG: /config/config.json
      BACKREST_DATA: /data
      XDG_CACHE_HOME: /cache
      BACKREST_RESTIC_COMMAND: /bin/restic
      TZ: UTC
    volumes:
      # Repository URIs, schedules and the login hash, written in step 3.
      - /srv/backrest/config:/config
      # oplog.sqlite, the JWT signing secret, the per-task logs.
      - /srv/backrest/data:/data
      # restic's cache: rebuildable, and it grows with the repository index.
      - /srv/backrest/cache:/cache
      - /srv/backrest/restore:/restore
      # The data this server holds: everything in this catalogue lives in /srv.
      - /srv:/userdata:ro
    ports:
      # Loopback only: the host's Caddy is the only thing that reaches 8136.
      - "127.0.0.1:8136:9898"
```

## compose.local.yml

```yaml
# Backrest · the deterministic fallback for the local path. Authored by
# caniselfhostit from the upstream documentation, not copied from a repository:
#   readme and docker ... https://github.com/garethgeorge/backrest/blob/v1.14.1/README.md
#   getting started ..... https://garethgeorge.github.io/backrest/introduction/getting-started
#   entrypoint defaults . https://github.com/garethgeorge/backrest/blob/v1.14.1/cmd/docker-entrypoint/main.go
#
# One service on the computer you are sitting at. Every path is relative to
# ~/selfhost/backrest/, which lets one file work on macOS, Linux and Windows.
# ../.. is the home directory two levels up, read-only: the files this install
# exists to copy somewhere safe. A restored file lands in ./restore.
# ./backups is mounted because the container writes its state as root, so on
# Linux step 8's archive is taken by the container. The VPS file needs no such
# mount: there, sudo does that job on the host.
#
# Tag and digest read from ghcr.io on 2026-08-06; the image publishes amd64,
# arm64, arm/v6, arm/v7 and 386.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  backrest:
    image: ghcr.io/garethgeorge/backrest:v1.14.1@sha256:b852979754281026230cc69fb11428e6d57c9a97784ab4a444ffc7934c53a215
    container_name: backrest
    restart: unless-stopped
    environment:
      BACKREST_PORT: "0.0.0.0:9898"
      BACKREST_CONFIG: /config/config.json
      BACKREST_DATA: /data
      XDG_CACHE_HOME: /cache
      BACKREST_RESTIC_COMMAND: /bin/restic
      TZ: UTC
    volumes:
      # Repository URIs, schedules and the login hash, from step 4.
      - ./config:/config
      - ./data:/data
      # restic's cache: rebuildable, and it grows with the repo index.
      - ./cache:/cache
      - ./restore:/restore
      # Where step 8 writes the archive of this configuration.
      - ./backups:/backups
      # Your home folder, read-only. The plan picks folders inside it.
      - ../..:/userdata:ro
    ports:
      # Loopback only: no other device on the wifi can reach 8136.
      - "127.0.0.1:8136:9898"
```

## Caddyfile

```text
# Backrest · the Caddy site block for this service.
#
# Authored by caniselfhostit from
# https://garethgeorge.github.io/backrest/cookbooks/reverse-proxy-examples and
# https://caddyserver.com/docs/automatic-https
#
# Append this to /etc/caddy/Caddyfile, the Caddy that Prompt Zero installed,
# with <DOMAIN> replaced by the hostname pointed at this box. Upstream's own
# example is one reverse_proxy line to 9898; the headers are ours.

<DOMAIN> {
	# Caddy's default encode matcher covers text, JSON, JavaScript and SVG
	# only, so a file downloaded from a snapshot passes through untouched.
	encode zstd gzip

	# Backrest sets no transport or frame headers of its own. HSTS is on
	# because every request here carries the token that reads this server.
	header {
		Strict-Transport-Security "max-age=31536000; includeSubDomains"
		X-Content-Type-Options "nosniff"
		X-Frame-Options "SAMEORIGIN"
		Referrer-Policy "no-referrer"
		-Server
	}

	# 8136 is the loopback port compose publishes on this host. It is not a
	# container port and it is not open in the firewall.
	reverse_proxy 127.0.0.1:8136
}
```

## install.sh

```bash
#!/usr/bin/env bash
# Backrest · 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=backup.example.com ./install.sh
#
# Authored by caniselfhostit from the upstream documentation:
#   https://github.com/garethgeorge/backrest/blob/v1.14.1/README.md
#   https://garethgeorge.github.io/backrest/introduction/getting-started
#   https://garethgeorge.github.io/backrest/docs/operations
#   https://github.com/garethgeorge/backrest/blob/v1.14.1/cmd/docker-entrypoint/main.go
#
# One secret is generated here, on this machine: the password for the web login.
# It goes into /srv/backrest/.env with mode 600 and is never printed. Its bcrypt
# hash goes into /srv/backrest/config/config.json before the container has ever
# run, because a Backrest that starts with no configuration file writes itself a
# default one with authentication disabled and then serves every API route to
# whoever asks.
#
# The repository passwords you type into the UI later are restic encryption
# passwords. Nothing here can recover one. Keep them where you keep this login.
#
# NOT YET VERIFIED: no harness run has been recorded against this script.
set -euo pipefail

APP_DIR="${APP_DIR:-/srv/backrest}"
DOMAIN_HOST="${DOMAIN_HOST:-}"
PORT="8136"

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. backup.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."
command -v openssl >/dev/null 2>&1 || die "openssl is not installed"

avail_mb="$(free -m | awk '/^Mem:/ {print $7}')"
[ "$avail_mb" -ge 1024 ] || die "only ${avail_mb} MB of RAM available; Backrest and restic want 1024 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 for the image and restic's cache"

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 ----------------------------------------------------
#
# config and backups belong to the login user; data, cache and restore are
# written by the container, which runs as root because the files it reads under
# /srv were written by other services as other users.

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

# --- 3. Generate the one secret, on the server -------------------------------
#
# Hex rather than base64, because a human types this one into a login form.
# Read it later with
#   sudo grep BACKREST_PASSWORD /srv/backrest/.env
# Backrest stores a password as a bcrypt hash that is then base64 encoded, and
# `caddy hash-password` piped into `base64` is exactly that. `version` is
# required: a configuration file with content and no version number is rejected
# at start-up, and 6 is the migration count this release ships.

if [ ! -f "$APP_DIR/.env" ]; then
	umask 077
	cat > "$APP_DIR/.env" <<-ENVFILE
		BACKREST_USERNAME=admin
		BACKREST_PASSWORD=$(openssl rand -hex 24)
	ENVFILE
	chmod 600 "$APP_DIR/.env"
	umask 022
fi

if [ ! -f "$APP_DIR/config/config.json" ]; then
	umask 077
	cat > "$APP_DIR/config/config.json" <<-'CONFIGJSON'
		{
		  "version": 6,
		  "instance": "INSTANCE_NAME",
		  "auth": {"disabled": false, "users": [{"name": "admin", "passwordBcrypt": "PLACEHOLDER"}]}
		}
	CONFIGJSON
	grep BACKREST_PASSWORD "$APP_DIR/.env" | cut -d= -f2- | caddy hash-password | base64 | tr -d '\n' > "$APP_DIR/config/hash.tmp"
	awk -v host="$DOMAIN_HOST" 'NR==FNR{h=$0;next} {sub(/PLACEHOLDER/,h);sub(/INSTANCE_NAME/,host);print}' \
		"$APP_DIR/config/hash.tmp" "$APP_DIR/config/config.json" > "$APP_DIR/config/config.tmp"
	mv "$APP_DIR/config/config.tmp" "$APP_DIR/config/config.json"
	rm "$APP_DIR/config/hash.tmp"
	chmod 600 "$APP_DIR/config/config.json"
	umask 022
fi
if grep -q PLACEHOLDER "$APP_DIR/config/config.json"; then
	die "the bcrypt hash was not substituted into config.json"
fi

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

# --- 4. 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-backrest"
	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

# --- 5. Ports: two open, and 8136 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; ${PORT} stays closed on loopback"
	sudo ufw allow 80/tcp
	sudo ufw allow 443/tcp
	sudo ufw allow 443/udp
	sudo ufw status verbose
fi

# --- 6. Start it -------------------------------------------------------------

docker compose pull
docker compose up -d

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

curl -sS "https://${DOMAIN_HOST}/" | grep -q '<title>Backrest</title>' \
	|| die "the page at https://${DOMAIN_HOST}/ is not Backrest's. Check the Caddy site block from step 4."

# The API must refuse a call with no credentials. If this returns 200 the
# configuration file was not read and the instance is open to the internet.
unauth="$(curl -sS -o /dev/null -w '%{http_code}' -X POST "https://${DOMAIN_HOST}/v1.Backrest/GetOperations" \
	-H 'Content-Type: application/json' -d '{}' || true)"
[ "$unauth" = "401" ] || die "an unauthenticated API call returned ${unauth}, not 401. Stop: authentication is off."

# And it must accept the generated one. The password goes to curl through a
# config on standard input, so it never appears in a command line or a log.
authed="$(printf 'user = "admin:%s"\n' "$(sudo grep BACKREST_PASSWORD "$APP_DIR/.env" | cut -d= -f2-)" \
	| curl -sS -o /dev/null -w '%{http_code}' -K - -X POST "https://${DOMAIN_HOST}/v1.Backrest/GetOperations" \
	-H 'Content-Type: application/json' -d '{}' || true)"
[ "$authed" = "200" ] || die "the generated login returned ${authed}, not 200. The hash and the password disagree."

# --- 7. The first backup, before day one ends --------------------------------

STAMP="$(date +%Y%m%d-%H%M%S)"
docker compose stop
sudo tar -czf "$APP_DIR/backups/backrest-config-${STAMP}.tar.gz" -C "$APP_DIR" config data compose.yml .env -C /etc/caddy Caddyfile
docker compose start
ls -lh "$APP_DIR/backups/"
[ -s "$APP_DIR/backups/backrest-config-${STAMP}.tar.gz" ] || die "the configuration archive is empty"

cat <<-DONE

	Backrest is answering at https://${DOMAIN_HOST}/ and refusing unauthenticated API calls.

	  1. Your login is admin. The password is in $APP_DIR/.env, mode 600. Read it with
	       sudo grep BACKREST_PASSWORD $APP_DIR/.env
	     and put it in your password manager. It was not printed here.
	  2. Nothing is backed up yet. Open https://${DOMAIN_HOST}/, log in, then Add Repo
	     for the storage your snapshots go to, then Add Plan for the paths. This
	     server's /srv is /userdata inside the container, and /userdata/backrest
	     belongs in the excludes: it is this app's own cache and history.
	  3. The repository password you type there is a restic encryption password.
	     Lose it and every snapshot is unreadable, including by you.
	  4. Restore one file from your first snapshot into /restore and look at it with
	       sudo ls -lR $APP_DIR/restore
	     A backup nobody has restored is a hope.
	  5. First backup of this install written to $APP_DIR/backups. It holds .env and
	     config.json, so it holds every repository password. 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/backblaze-personal-backup/ · How the verdict, the timings and the prices are derived: https://caniselfhostit.com/methodology/ · Source, data and corrections: https://github.com/caniselfhostit/caniselfhostit
