# Can I self-host Firebase?

**YES** — it's called PocketBase. ONE COMMAND setup · ~8 minutes to running · 512 MB RAM minimum.

PocketBase authored from upstream docs · not yet machine-verified · source: https://caniselfhostit.com/self-host/firebase-blaze/

## 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 PocketBase 0.39.10 on that server, reachable at https://<DOMAIN>, behind the existing
Caddy with automatic TLS.

## 1. Preflight

If `<DOMAIN>` or `<ADMIN_EMAIL>` is still literal, ask the user for both once and stop until
they answer. The A record for `<DOMAIN>` must already point at this server. `<ADMIN_EMAIL>` is
the address the superuser account is created under, and it is the name the user types into the
dashboard login form. No mail is ever sent to it by this install.

PocketBase needs 512 MB of RAM available and 5 GB free on /srv. The image publishes amd64,
arm64 and armv7. Measure all four before installing:

```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 512 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 hostname that does not resolve, and failed attempts count against a rate
limit nobody can see.

## 2. Layout

The container runs as uid 1000, so the data directory belongs to 1000 rather than to the login
user. Backups stay with the login user, because the login user is who copies them off the box.

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

Assert: `ls -la` shows `data` owned by `1000` and `backups` owned by the login user. Nothing is
written outside /srv/pocketbase. Keep `data` on local disk: it holds a SQLite database, and
SQLite on a network mount corrupts quietly and weeks later.

## 3. Secrets

One secret: the password of the first superuser account. Generate it on the server. Do not
print it, do not repeat it in your summary, and do not put it in any log line. Hex rather than
base64, because the user retypes this string into a browser login form and hex has no
characters they can mistake for each other.

```bash
umask 077
cat > /srv/pocketbase/.env <<EOF
PB_ADMIN_EMAIL=<ADMIN_EMAIL>
PB_ADMIN_PASSWORD=$(openssl rand -hex 24)
EOF
chmod 600 /srv/pocketbase/.env
umask 022
ls -l /srv/pocketbase/.env
```

Assert: the file exists with mode `-rw-------`. The image's entrypoint reads those two
variables and runs `pocketbase superuser upsert` against the data directory before it starts
the web server, so the superuser exists before the port ever answers a request. There is no
default account, no blank password and no open signup window to close afterwards, which is why
step 7 asserts the API refuses an unauthenticated call rather than asserting a form went away.

Tell the user: their password is in /srv/pocketbase/.env, they read it with
`sudo grep PB_ADMIN_PASSWORD /srv/pocketbase/.env`, and it belongs in their password manager
now.

## 4. compose.yml

```bash
cat > /srv/pocketbase/compose.yml <<'EOF'
# PocketBase · the deterministic fallback. Authored by caniselfhostit from the
# upstream documentation, not copied from a repository:
#   introduction ....... https://pocketbase.io/docs/
#   production notes ... https://pocketbase.io/docs/going-to-production/
#   health endpoint .... https://pocketbase.io/docs/api-health/
#   image entrypoint ... https://github.com/muchobien/pocketbase-docker/blob/22f36a08837f26b22a3327cb8066ad63c3362c70/entrypoint.sh
#
# One container. PocketBase is a single Go binary with SQLite compiled into it,
# so there is no database service here, and no Caddy service either: Prompt Zero
# already runs Caddy under systemd on the host.
#
# The PocketBase project publishes no image. Its production page states that
# PocketBase doesn't have an official Docker image, so this file uses
# ghcr.io/muchobien/pocketbase, built outside the PocketBase project from the
# revision named above, which is the one this digest was built from. That
# Dockerfile downloads upstream's own release zip for the target architecture
# and unpacks it, and it does not check that zip against the checksums.txt
# upstream publishes beside it. Neither does the example Dockerfile in
# upstream's own docs. What fixes the bytes you run is the digest below, which
# names one build and nothing else, and step 7 asserts the binary inside it
# reports 0.39.10. Digest read from ghcr.io on 2026-08-07; the index carries
# linux/amd64, linux/arm64 and linux/armv7.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  pocketbase:
    image: ghcr.io/muchobien/pocketbase:0.39.10@sha256:dfebd2550d6b5176d67afd3e859f9b642096e624c7f6ada1b5a5bc70a5d21be1
    container_name: pocketbase
    restart: unless-stopped
    # The image declares no USER, so without this line it runs as root.
    # PocketBase writes nothing outside its data directory, so uid 1000 is
    # enough, and step 2 hands that directory to 1000.
    user: "1000:1000"
    env_file: /srv/pocketbase/.env
    environment:
      # Inside the container the server has to listen on every interface, or
      # the loopback port published on the host reaches nothing. 8090 is the
      # port the image's entrypoint defaults to, named here so a change to
      # that default cannot move it under the healthcheck and the Caddy block.
      PB_HOST: "0.0.0.0"
      PB_PORT: "8090"
    volumes:
      # The one mount: data.db, every uploaded file, and PocketBase's own
      # backup archives. Local disk only: SQLite needs real POSIX file locks,
      # and a network mount corrupts it quietly.
      - /srv/pocketbase/data:/pb_data
    healthcheck:
      test: ["CMD-SHELL", "wget -q -O /dev/null http://127.0.0.1:8090/api/health || exit 1"]
      start_period: 10s
      interval: 15s
      retries: 10
    ports:
      # Loopback only. The host's Caddy is the only thing that reaches 8166,
      # and 8166 never enters the firewall.
      - "127.0.0.1:8166:8090"
EOF
cd /srv/pocketbase && docker compose config >/dev/null && echo "compose OK"
```

Assert: that prints `compose OK`. The container serves on 8090 inside itself, 8166 is bound to
127.0.0.1 on the host, and Caddy is the only route in.

## 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 here takes down every other site on the box.

```bash
sudo cp /etc/caddy/Caddyfile /etc/caddy/Caddyfile.before-pocketbase
printf '\n' | sudo tee -a /etc/caddy/Caddyfile >/dev/null
sudo tee -a /etc/caddy/Caddyfile >/dev/null <<'EOF'
# PocketBase · the Caddy site block for this service.
#
# Authored by caniselfhostit from
# https://pocketbase.io/docs/going-to-production/ and
# https://caddyserver.com/docs/caddyfile/directives/reverse_proxy
#
# Append this to /etc/caddy/Caddyfile, the Caddy that Prompt Zero installed, with
# <DOMAIN> replaced by the hostname pointed at this box. Caddy runs under systemd
# on the host. There is no Caddy container anywhere in this project.

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

	# No `encode` directive here, on purpose. PocketBase's realtime endpoint is
	# a long-lived text/event-stream, which Caddy flushes to the client
	# immediately instead of buffering, and a compressor in front of a stream
	# that carries JSON this small earns nothing.
	#
	# reverse_proxy sets X-Forwarded-For itself and ignores whatever the client
	# sent in that header, which is what makes it safe to name in PocketBase's
	# User IP proxy headers setting.
	#
	# 8166 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:8166
}
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-pocketbase, reload, and report what it objected to. Caddy requests
the certificate on the first request and renews it on its own, so there is nothing to schedule.

## 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, and
443/udp is HTTP/3. 8166 stays closed because compose binds it to 127.0.0.1, so a rule for it
would cover traffic that cannot arrive; if a previous run left one, `sudo ufw delete allow 8166`
removes it. Assert: `ufw status verbose` prints `Status: active`, shows 80, 443/tcp and 443/udp,
and no rule for 8166.

## 7. Start and verify

The entrypoint creates the superuser from the two variables in .env, then starts the server.

```bash
cd /srv/pocketbase
docker compose pull
docker compose up -d
docker compose exec -T pocketbase pocketbase --version
for i in $(seq 1 30); do code=$(curl -sS -o /dev/null -w '%{http_code}' https://<DOMAIN>/api/health); echo "$i $code"; [ "$code" = 200 ] && break; sleep 5; done
curl -sS https://<DOMAIN>/api/health
echo
curl -sS -o /dev/null -w '%{http_code}\n' https://<DOMAIN>/api/collections
docker compose logs pocketbase | grep -c 'Successfully saved superuser'
```

Assert, all five, and print what you received for each. The version line contains `0.39.10`,
which is how you know the community image carries upstream's release binary and not an older
one. The loop ends printing `200`. The health body contains
`"message":"API is healthy."`. The unauthenticated call to `/api/collections` prints `401`,
which is the security assert in this block: that route requires a superuser token and refuses
without one. The last command prints a number of at least `1`, meaning the superuser account
was written before the server accepted its first request.

If any of the five misses, stop, run `docker compose logs --tail 40 pocketbase`, and say which
earlier step is the likely cause. A `403` where a `401` was expected means something else is
answering on that hostname. `curl: (35)` or a certificate error points at step 5 or at DNS. A
container that restarts in a loop with a permissions error on `/pb_data` points at step 2. A
running container is not success.

The first screen at https://<DOMAIN>/_/ is a login form headed `Superuser login`, with an email
field and a password field and no way to create an account.

STOP: tell the user to open https://<DOMAIN>/_/, read their password with
`sudo grep PB_ADMIN_PASSWORD /srv/pocketbase/.env`, sign in with `<ADMIN_EMAIL>` and that
password, and save both in their password manager. Wait until they are looking at the
dashboard. Do not continue until they confirm.

## 8. First backup and restore

Take the backup now, before the user creates a single collection. Stop the container first:
upstream says plainly that copying `pb_data` is the backup, and that the application must not be
running while it happens.

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

Assert: the archive exists and is non-empty. Print its size. Downtime is a few seconds. That one
archive is the whole install: the SQLite database with every account and record, the uploaded
files, the compose file, the superuser password and the live Caddy site block.

A backup on the same disk as the data is not a backup, so run this one from the user's machine:

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

To restore: `docker compose down`, `sudo rm -rf /srv/pocketbase/data`, untar the archive back
into /srv/pocketbase, `sudo chown -R 1000:1000 /srv/pocketbase/data`, then `docker compose up -d`.
The Caddy site block comes out of the same archive at `Caddyfile` and goes back into
/etc/caddy/Caddyfile by hand, because that file also holds every other site on the box. Tell the
user those five commands are the whole disaster plan.

## 9. Updating later

New versions are listed at https://github.com/pocketbase/pocketbase/releases, and the image
tags that follow them are at
https://github.com/muchobien/pocketbase-docker/pkgs/container/pocketbase. Take a backup first,
then edit the image line in /srv/pocketbase/compose.yml to the new tag and its digest:

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

PocketBase runs its own database migrations on the way up, so watch that log until it settles,
then re-run the health check and the version check from step 7 before calling the update done.
Read the upstream release notes first: PocketBase is pre-1.0, and breaking changes land in minor
releases rather than waiting for a major one.

## 10. What will probably go wrong

The password will come back. I changed my superuser password inside the dashboard, restarted the
container a week later, and could not sign in with the new one. Nothing was broken: the image's
entrypoint runs `superuser upsert` from `PB_ADMIN_EMAIL` and
`PB_ADMIN_PASSWORD` on every single start, so the value in /srv/pocketbase/.env wins over
whatever the dashboard was told, every time the container comes up. Treat that file as the
source of truth. To change the password, edit .env and run `docker compose up -d --force-recreate`,
and if the user ever wants the dashboard to own it instead, delete the `PB_ADMIN_PASSWORD` line
from .env after they have set their own.

## 11. Out of scope

- Do not configure SMTP or S3 file storage. PocketBase's core loop needs neither, the superuser
  account this install creates needs no mail, and uploaded files belong in /pb_data, which is
  what step 8 backs up.
- Do not set `--encryptionEnv`. That flag encrypts the SMTP password and the S3 credentials
  stored in the database, and this install configures neither of them.
- Do not add mounts for /pb_public or /pb_hooks. Serving a frontend and writing JavaScript
  hooks are compose edits the user makes once they have something to put in them.
- Do not build an application on top of this. Collections and API rules are the user's work,
  and each is a decision this prompt has no business making.
````

## 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 PocketBase 0.39.10 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, and `<ADMIN_EMAIL>` with the address your superuser account will be created
under. Nothing in this install sends mail to that address: it is the name you type into the
dashboard login form.

One thing to know before step 1. PocketBase publishes no Docker image of its own, and says so
in its own production documentation. The image below is a community build maintained outside
the PocketBase project, at github.com/muchobien/pocketbase-docker. Its Dockerfile downloads
upstream's release zip and unpacks it without checking it against the checksums.txt upstream
publishes beside it, which upstream's own example Dockerfile also does not do. What fixes the
bytes you run is the digest in the image line, which names exactly one build, and step 7 asks
the binary inside it which version it is.

## 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 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. Caddy cannot get a certificate for a hostname that does not
resolve, and failed attempts count against a rate limit you cannot see. An IP that is not your
server's usually means a proxying CDN sits in front of the record; turn that off for this
hostname while you install, because the certificate would otherwise be issued to somebody
else's edge. If free memory is under 512 MB, this will still boot and then behave strangely
under the first real load; add swap or move to a bigger box rather than continuing.

## 2. Layout

The container runs as uid 1000, so its data directory belongs to 1000 rather than to you.
Backups stay yours, because you are the one who copies them off the box.

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

You should see: `backups` owned by you, and `data` owned by `1000`.

If you do not: `data` owned by your own username means the second line did not run, and the
container will fail on its first write with a permissions error on `/pb_data`. Run the second
line again on its own. Keep this directory on the server's local disk: it holds a SQLite
database, and SQLite on a network mount corrupts quietly and weeks later.

## 3. Secrets

One secret: the password of your first superuser account. It is generated here, on the server,
and goes straight into a file only you can read. Hex rather than base64, because you are going
to retype this string into a browser login form and hex has no characters you can mistake for
each other.

```bash
umask 077
cat > /srv/pocketbase/.env <<EOF
PB_ADMIN_EMAIL=<ADMIN_EMAIL>
PB_ADMIN_PASSWORD=$(openssl rand -hex 24)
EOF
chmod 600 /srv/pocketbase/.env
umask 022
ls -l /srv/pocketbase/.env
```

You should see: mode `-rw-------`, your own username twice, and the path. Replace
`<ADMIN_EMAIL>` on the first line with your real address before you paste. Read the password
once with `sudo grep PB_ADMIN_PASSWORD /srv/pocketbase/.env` and put it in your password
manager: it is the only credential this install has.

If you do not: a mode of `-rw-r--r--` means `umask 077` did not take effect, which happens if
you pasted the lines separately into different shells. Run `chmod 600 /srv/pocketbase/.env` and
carry on. If the file already existed from an earlier attempt, this block has now overwritten
the password, which is harmless: the container applies whatever is in this file at its next
start, so the new value becomes the real one.

Do not paste that file, the password, or any command output containing it into this chat
window. The agent path never sees those values; this path will hand them to a third party
unless you make a point of not doing it.

## 4. compose.yml

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

```bash
cat > /srv/pocketbase/compose.yml <<'EOF'
# PocketBase · the deterministic fallback. Authored by caniselfhostit from the
# upstream documentation, not copied from a repository:
#   introduction ....... https://pocketbase.io/docs/
#   production notes ... https://pocketbase.io/docs/going-to-production/
#   health endpoint .... https://pocketbase.io/docs/api-health/
#   image entrypoint ... https://github.com/muchobien/pocketbase-docker/blob/22f36a08837f26b22a3327cb8066ad63c3362c70/entrypoint.sh
#
# One container. PocketBase is a single Go binary with SQLite compiled into it,
# so there is no database service here, and no Caddy service either: Prompt Zero
# already runs Caddy under systemd on the host.
#
# The PocketBase project publishes no image. Its production page states that
# PocketBase doesn't have an official Docker image, so this file uses
# ghcr.io/muchobien/pocketbase, built outside the PocketBase project from the
# revision named above, which is the one this digest was built from. That
# Dockerfile downloads upstream's own release zip for the target architecture
# and unpacks it, and it does not check that zip against the checksums.txt
# upstream publishes beside it. Neither does the example Dockerfile in
# upstream's own docs. What fixes the bytes you run is the digest below, which
# names one build and nothing else, and step 7 asserts the binary inside it
# reports 0.39.10. Digest read from ghcr.io on 2026-08-07; the index carries
# linux/amd64, linux/arm64 and linux/armv7.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  pocketbase:
    image: ghcr.io/muchobien/pocketbase:0.39.10@sha256:dfebd2550d6b5176d67afd3e859f9b642096e624c7f6ada1b5a5bc70a5d21be1
    container_name: pocketbase
    restart: unless-stopped
    # The image declares no USER, so without this line it runs as root.
    # PocketBase writes nothing outside its data directory, so uid 1000 is
    # enough, and step 2 hands that directory to 1000.
    user: "1000:1000"
    env_file: /srv/pocketbase/.env
    environment:
      # Inside the container the server has to listen on every interface, or
      # the loopback port published on the host reaches nothing. 8090 is the
      # port the image's entrypoint defaults to, named here so a change to
      # that default cannot move it under the healthcheck and the Caddy block.
      PB_HOST: "0.0.0.0"
      PB_PORT: "8090"
    volumes:
      # The one mount: data.db, every uploaded file, and PocketBase's own
      # backup archives. Local disk only: SQLite needs real POSIX file locks,
      # and a network mount corrupts it quietly.
      - /srv/pocketbase/data:/pb_data
    healthcheck:
      test: ["CMD-SHELL", "wget -q -O /dev/null http://127.0.0.1:8090/api/health || exit 1"]
      start_period: 10s
      interval: 15s
      retries: 10
    ports:
      # Loopback only. The host's Caddy is the only thing that reaches 8166,
      # and 8166 never enters the firewall.
      - "127.0.0.1:8166:8090"
EOF
cd /srv/pocketbase && docker compose config >/dev/null && echo "compose OK"
```

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

If you do not: `env file /srv/pocketbase/.env not found` means step 3 did not write the file.
`services must be a mapping` means the indentation was lost between the page and your terminal;
run `rm /srv/pocketbase/compose.yml` and paste again in one go. The container serves on 8090
inside itself and 8166 is bound to 127.0.0.1 on the host, so Caddy is the only route in. Do not
add a Caddy service to this file: Caddy already runs under systemd on this box, and a container
claiming 80 and 443 would fail to start and take every other site down with it.

## 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-pocketbase
printf '\n' | sudo tee -a /etc/caddy/Caddyfile >/dev/null
sudo tee -a /etc/caddy/Caddyfile >/dev/null <<'EOF'
# PocketBase · the Caddy site block for this service.
#
# Authored by caniselfhostit from
# https://pocketbase.io/docs/going-to-production/ and
# https://caddyserver.com/docs/caddyfile/directives/reverse_proxy
#
# Append this to /etc/caddy/Caddyfile, the Caddy that Prompt Zero installed, with
# <DOMAIN> replaced by the hostname pointed at this box. Caddy runs under systemd
# on the host. There is no Caddy container anywhere in this project.

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

	# No `encode` directive here, on purpose. PocketBase's realtime endpoint is
	# a long-lived text/event-stream, which Caddy flushes to the client
	# immediately instead of buffering, and a compressor in front of a stream
	# that carries JSON this small earns nothing.
	#
	# reverse_proxy sets X-Forwarded-For itself and ignores whatever the client
	# sent in that header, which is what makes it safe to name in PocketBase's
	# User IP proxy headers setting.
	#
	# 8166 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:8166
}
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-pocketbase /etc/caddy/Caddyfile`,
reload, and paste again. The most common cause is a `<DOMAIN>` you replaced in one place and
not the other. Caddy requests the certificate on the first request to the hostname and renews
it on its own, so there is no cron job to add and nothing to schedule.

## 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 `8166`.

If you do not: delete anything for `8166` with `sudo ufw delete allow 8166`. That port is bound
to 127.0.0.1 by the compose file, so a rule for it would cover traffic that cannot arrive.
80/tcp is there to answer the ACME challenge and redirect 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

The image's entrypoint creates your superuser account from the two variables in .env, then
starts the server. The account therefore exists before the port answers its first request,
which is why there is no setup window here for somebody else to walk into.

```bash
cd /srv/pocketbase
docker compose pull
docker compose up -d
docker compose exec -T pocketbase pocketbase --version
for i in $(seq 1 30); do code=$(curl -sS -o /dev/null -w '%{http_code}' https://<DOMAIN>/api/health); echo "$i $code"; [ "$code" = 200 ] && break; sleep 5; done
curl -sS https://<DOMAIN>/api/health
echo
curl -sS -o /dev/null -w '%{http_code}\n' https://<DOMAIN>/api/collections
docker compose logs pocketbase | grep -c 'Successfully saved superuser'
```

You should see, in order: a version line containing `0.39.10`, the loop reaching `200`, a small
JSON object containing `"message":"API is healthy."`, then `401`, then a number of at least `1`.

If you do not: the `401` is the one worth understanding. It means the API is up and refusing a
call that carries no superuser token, so seeing it is good news, and a `200` there would mean
something is very wrong. If the loop never reaches `200`, run
`docker compose logs --tail 40 pocketbase` first: a restart loop mentioning `/pb_data` is step 2
done wrong, and a clean log with no HTTP answer is usually Caddy still waiting on DNS. A count
of `0` on the last line means the entrypoint found no `PB_ADMIN_EMAIL` and `PB_ADMIN_PASSWORD`,
so step 3 wrote the file somewhere else or the compose file is not reading it. A version line
that does not say `0.39.10` means the digest in the image line was edited; put it back.

Now open https://<DOMAIN>/_/ in a browser. The first screen is a login form headed
`Superuser login`, with an email field, a password field and no way to create an account. Read
your password with `sudo grep PB_ADMIN_PASSWORD /srv/pocketbase/.env`, sign in with the address
you put in `<ADMIN_EMAIL>`, and save both in your password manager.
A running container is not success; this screen and that sign-in are.

## 8. First backup and restore

Take the backup now, before you create a single collection. The container stops first, because
upstream says plainly that copying `pb_data` is the backup and that the application must not be
running while it happens.

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

You should see: one file, a few hundred kilobytes on a fresh install. Downtime is a few seconds.

If you do not: an archive of about 100 bytes means `tar` found nothing at those paths, so check
you are in /srv/pocketbase. That one archive is the whole install: the SQLite database with
every account and record, the uploaded files, the compose file, your password, and the live
Caddy site block from /etc/caddy, which is where the `-C /etc/caddy Caddyfile` at the end comes
from.

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/pocketbase
scp vps:/srv/pocketbase/backups/*.tar.gz ~/backups/pocketbase/
```

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

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 `vps` alias Prompt Zero created lives.

Now prove the restore, today, while the only thing at risk is an empty database:

```bash
cd /srv/pocketbase
docker compose down
sudo rm -rf /srv/pocketbase/data
sudo install -d -m 750 -o 1000 -g 1000 /srv/pocketbase/data
sudo tar -xzf /srv/pocketbase/backups/pocketbase-$(date +%F).tar.gz -C /srv/pocketbase data
sudo chown -R 1000:1000 /srv/pocketbase/data
docker compose up -d
sleep 10
curl -sS -o /dev/null -w '%{http_code}\n' https://<DOMAIN>/api/health
```

You should see: `200`, and your existing password still signing you in at https://<DOMAIN>/_/.

If you do not: a restart loop after a restore is nearly always the `chown` line being skipped,
because `tar` restored the files as root. Run it and `docker compose up -d` again. Note what
the archive does not put back on its own: the Caddy site block is in it at `Caddyfile`, and
restoring that means opening the file and pasting the block into /etc/caddy/Caddyfile by hand,
because that file also holds every other site on the box.

## 9. Updating later

New versions are listed at https://github.com/pocketbase/pocketbase/releases, and the image
tags that follow them are at
https://github.com/muchobien/pocketbase-docker/pkgs/container/pocketbase. Take a backup first,
then edit the `image:` line in /srv/pocketbase/compose.yml to the new tag and its digest.

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

You should see: migration output, 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. Then re-run
step 7's health check and the version check before you call the update done. Read the upstream
release notes before every bump: PocketBase is pre-1.0 and says so, and breaking changes land
in minor releases rather than waiting for a major one, so a jump of two minor versions can want
a change in your own code.

## 10. What will probably go wrong

The password will come back. I changed my superuser password inside the dashboard, restarted
the container a week later, and could not sign in with the new one. Nothing was broken: the
image's entrypoint runs `superuser upsert` from `PB_ADMIN_EMAIL` and `PB_ADMIN_PASSWORD` on
every single start, so the value in /srv/pocketbase/.env wins over whatever the dashboard was
told, every time the container comes up. Treat that file as the source of truth. To change the
password, edit .env and run `docker compose up -d --force-recreate`, and if you would rather
the dashboard owned it, delete the `PB_ADMIN_PASSWORD` line from .env once you have set your
own.

## 11. Out of scope

- Do not configure SMTP or S3 file storage. PocketBase's core loop needs neither, the superuser
  account this install creates needs no mail, and uploaded files belong in /pb_data, which is
  what step 8 backs up.
- Do not set `--encryptionEnv`. That flag encrypts the SMTP password and the S3 credentials
  stored in the database, and this install configures neither of them.
- Do not add mounts for /pb_public or /pb_hooks. Serving a frontend and writing JavaScript
  hooks are compose edits you make once you have something to put in them.
- Do not build an application on top of this. Collections and API rules are your work, and each
  is a decision this install has no business making for you.
````

## 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 PocketBase 0.39.10 under ~/selfhost/pocketbase, answering at http://localhost:8166.

## 1. Preflight

Say this to the user before step 2 runs; it decides whether they want this install at all.
PocketBase is a backend, so its worth is that another program calls it, and it answers only at
http://localhost:8166. The app they build against it works in a browser here; the phone they
wanted to test from gets a connection error. What they keep is a real auth, database and file
API of their own.

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. PocketBase needs 512 MB of RAM available
and 5 GB free on the home disk; the image publishes amd64, arm64 and armv7. On macOS and
Windows that figure is the host's, and Docker Desktop's VM takes its share of it. If RAM is
under 512 MB or free disk under 5 GB, print both and stop.

## 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/pocketbase/data ~/selfhost/pocketbase/backups
ls -la ~/selfhost/pocketbase
```

`data` holds the SQLite database and every uploaded file, `backups` step 8's archives. On
Linux only, the container runs as uid 1000 against a real directory, so hand `data` over:

```bash
sudo chown -R 1000:1000 ~/selfhost/pocketbase/data
```

Do not run that on macOS or Windows, where Docker Desktop rewrites ownership across its file
share and the container already sees itself as the owner.

Assert: `ls -la` lists both directories, and on Linux `data` belongs to `1000`. Keep this off
any folder a sync service watches: SQLite needs real POSIX file locks.

## 4. Secrets

One secret: the password of the first superuser account. Generate it here, print it nowhere,
and keep it out of your summary and any log line. Hex rather than base64, because the user
retypes it into a login form.

```bash
umask 077
cat > ~/selfhost/pocketbase/.env <<EOF
PB_ADMIN_EMAIL=admin@example.com
PB_ADMIN_PASSWORD=$(openssl rand -hex 24)
EOF
chmod 600 ~/selfhost/pocketbase/.env
umask 022
ls -l ~/selfhost/pocketbase/.env
```

Assert: the file exists with mode `-rw-------`. Git Bash ships openssl, so these lines run the
same everywhere. On Windows those mode bits are advisory: NTFS does not enforce them, and the
real boundary is the user's own Windows account.

`admin@example.com` is a login name, not a mailbox. Tell the user their password is in that
file, read with `grep PB_ADMIN_PASSWORD ~/selfhost/pocketbase/.env`, and belongs in a password
manager now. The file stays the source of truth: the entrypoint runs `superuser upsert` from
those two variables at every start, so a password changed in the dashboard is overwritten at
the next restart.

## 5. compose.yml

```bash
cat > ~/selfhost/pocketbase/compose.yml <<'EOF'
# PocketBase · the deterministic fallback for the local path. Authored by
# caniselfhostit from the upstream documentation, not copied from a repository:
#   production notes ... https://pocketbase.io/docs/going-to-production/
#   health endpoint .... https://pocketbase.io/docs/api-health/
#   image entrypoint ... https://github.com/muchobien/pocketbase-docker/blob/22f36a08837f26b22a3327cb8066ad63c3362c70/entrypoint.sh
#
# One container. Paths are relative to ~/selfhost/pocketbase/, so one file
# works on macOS, Linux and Windows, and nothing off this machine reaches 8166.
#
# The PocketBase project publishes no image: upstream's production page states
# that PocketBase doesn't have an official Docker image. This file therefore
# uses ghcr.io/muchobien/pocketbase, packaged outside that project at the
# revision above, the one this digest was built from. Its Dockerfile unpacks
# upstream's release zip without checking it against the checksums.txt
# published beside it, and neither does upstream's own example Dockerfile, so
# what fixes these bytes is the digest below: step 7 asserts the binary in it
# reports 0.39.10. Read from ghcr.io 2026-08-07; amd64, arm64 and armv7.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  pocketbase:
    image: ghcr.io/muchobien/pocketbase:0.39.10@sha256:dfebd2550d6b5176d67afd3e859f9b642096e624c7f6ada1b5a5bc70a5d21be1
    container_name: pocketbase
    restart: unless-stopped
    # The image declares no USER, so without this it runs as root and what it
    # writes into ./data on Linux comes back owned by root.
    user: "1000:1000"
    env_file: ./.env
    environment:
      # It has to listen on every interface inside the container or the
      # published loopback port reaches nothing. 8090 is the entrypoint's own
      # default, named here so a change to it cannot move the port.
      PB_HOST: "0.0.0.0"
      PB_PORT: "8090"
    volumes:
      # The one mount: SQLite database, uploaded files, and PocketBase's own
      # backup archives. Keep off synced folders: SQLite wants POSIX locks.
      - ./data:/pb_data
    healthcheck:
      test: ["CMD-SHELL", "wget -q -O /dev/null http://127.0.0.1:8090/api/health || exit 1"]
      start_period: 10s
      interval: 15s
      retries: 10
    ports:
      # Loopback only. No other device on this network reaches 8166, not even
      # your own phone.
      - "127.0.0.1:8166:8090"
EOF
cd ~/selfhost/pocketbase && docker compose config >/dev/null && echo "compose OK"
```

Assert: that prints `compose OK`. One service, one published port, one bind mount.

## 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 and nothing to wait for.
- No TLS. A certificate attests a public name and nothing here has one. Browsers treat
  http://localhost as a secure context anyway, so the dashboard's crypto works.
- No firewall rule. Nothing is published beyond loopback, so no port needs closing.

8166 is bound to 127.0.0.1, this computer only: not the user's phone, not a laptop on the same
wifi, not anyone on the internet. Confirm it:

```bash
grep -c '"127.0.0.1:' ~/selfhost/pocketbase/compose.yml
```

Assert: that prints `1`, the published port line. The pattern carries the opening quote so the
healthcheck's own `http://127.0.0.1:8090` is not counted.

## 7. Start and verify

The entrypoint creates the superuser from .env, then starts the server.

```bash
cd ~/selfhost/pocketbase
docker compose pull
docker compose up -d
docker compose exec -T pocketbase pocketbase --version
for i in $(seq 1 24); do code=$(curl -sS -o /dev/null -w '%{http_code}' http://localhost:8166/api/health); echo "$i $code"; [ "$code" = 200 ] && break; sleep 5; done
curl -sS http://localhost:8166/api/health
echo
curl -sS -o /dev/null -w '%{http_code}\n' http://localhost:8166/api/collections
docker compose logs pocketbase | grep -c 'Successfully saved superuser'
```

Assert all five, and print what you received for each: the version line contains `0.39.10`, so
this image carries upstream's release binary; the loop ends on `200`; the health body contains
`"message":"API is healthy."`; `/api/collections` prints `401`, the security assert here,
because that route wants a superuser token; the last prints at least `1`, so the superuser
existed before the first request.

If any misses, stop, run `docker compose logs --tail 40 pocketbase`, and name the likely cause.
A restart loop complaining about `/pb_data` means step 3's chown did not run on Linux. If
`port is already allocated` came back, find what holds 8166
(`lsof -nP -iTCP:8166 -sTCP:LISTEN`, or `netstat -ano | findstr :8166`) and stop until the
user frees it. A running container is not success.

The first screen at http://localhost:8166/_/ is a login form headed `Superuser login`, with an
email field, a password field and no way to create an account.

STOP: tell the user to open http://localhost:8166/_/, read their password with
`grep PB_ADMIN_PASSWORD ~/selfhost/pocketbase/.env`, sign in as `admin@example.com`, and save
both in their password manager. Do not continue until they confirm.

## 8. First backup and restore

Take the backup now, before the user creates a collection. Stop the container first: upstream
says copying `pb_data` is the backup, and that the application must not be running for it.

```bash
cd ~/selfhost/pocketbase
docker compose stop
tar -czf backups/pocketbase-$(date +%F).tar.gz data compose.yml .env
docker compose start
ls -lh backups/
```

Assert: the archive exists and is non-empty, and `tar -tzf` on it lists `data/data.db`. Print
its size. On Linux, if `tar` prints `Cannot open: Permission denied`, the login user is not uid
1000: rerun with `sudo`, and say the archive now belongs to root. It is the whole install: the
database, the uploaded files, the compose file and the password.

A backup on the same disk is not a backup, and on one computer the disk and the machine fail
together. Get a copy off this machine now: ask which folder a sync service already watches
(iCloud Drive, OneDrive, Dropbox, Syncthing), or have them plug in a USB stick, under /Volumes
on macOS, usually /media on Linux, a drive letter such as /d in Git Bash. Confirm it with
`ls -d`, `cp` the archive there, print the result, and do not guess a path: `~/Dropbox` is
absent on most machines.

To restore, run `ls -lh backups/`, have the user name the archive, and put that filename in
both `ARCHIVE` slots. Nothing is deleted until `tar -tzf` has read it through:

```bash
cd ~/selfhost/pocketbase
tar -tzf backups/ARCHIVE >/dev/null && docker compose down && rm -rf data && tar -xzf backups/ARCHIVE && docker compose up -d
```

That one line is the whole disaster plan. On Linux, if the login user is not uid 1000, run the
`rm -rf` and the `tar` under `sudo`, then chown `data` back to 1000 before starting.

## 9. Updating later

New versions are at https://github.com/pocketbase/pocketbase/releases, and the image tags that
follow them at https://github.com/muchobien/pocketbase-docker/pkgs/container/pocketbase. Back
up first, then edit the image line in ~/selfhost/pocketbase/compose.yml to the new digest:

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

PocketBase migrates its own database on the way up, so watch that log settle, then re-run
step 7's health and version checks. PocketBase is pre-1.0 and breaking changes land in
minor releases, so read the release notes first.

## 10. What will probably go wrong

I rebooted, opened the app I was building, and every API call came back as a connection error
that read exactly like a bug in my code. It was not: Docker Desktop had not started with
the session, so nothing was listening on 8166. `restart: unless-stopped` acts only once the
Docker daemon is up. Turn on its start-at-login setting, and after a reboot run
`cd ~/selfhost/pocketbase && docker compose up -d` before suspecting your own code.

## 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 rebind 8166 to 0.0.0.0 so a phone on the same wifi can reach it. That puts an
  application server holding real accounts on every network the user joins.
- Do not configure SMTP or S3. PocketBase works without either.
- Do not add mounts for /pb_public or /pb_hooks. Serving a frontend and writing JavaScript
  hooks are compose edits for when the user has something to put in them.
- Do not build an application on top of this. Collections and API rules are the user's work.
````

## docker-compose.yml

```yaml
# PocketBase · the deterministic fallback. Authored by caniselfhostit from the
# upstream documentation, not copied from a repository:
#   introduction ....... https://pocketbase.io/docs/
#   production notes ... https://pocketbase.io/docs/going-to-production/
#   health endpoint .... https://pocketbase.io/docs/api-health/
#   image entrypoint ... https://github.com/muchobien/pocketbase-docker/blob/22f36a08837f26b22a3327cb8066ad63c3362c70/entrypoint.sh
#
# One container. PocketBase is a single Go binary with SQLite compiled into it,
# so there is no database service here, and no Caddy service either: Prompt Zero
# already runs Caddy under systemd on the host.
#
# The PocketBase project publishes no image. Its production page states that
# PocketBase doesn't have an official Docker image, so this file uses
# ghcr.io/muchobien/pocketbase, built outside the PocketBase project from the
# revision named above, which is the one this digest was built from. That
# Dockerfile downloads upstream's own release zip for the target architecture
# and unpacks it, and it does not check that zip against the checksums.txt
# upstream publishes beside it. Neither does the example Dockerfile in
# upstream's own docs. What fixes the bytes you run is the digest below, which
# names one build and nothing else, and step 7 asserts the binary inside it
# reports 0.39.10. Digest read from ghcr.io on 2026-08-07; the index carries
# linux/amd64, linux/arm64 and linux/armv7.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  pocketbase:
    image: ghcr.io/muchobien/pocketbase:0.39.10@sha256:dfebd2550d6b5176d67afd3e859f9b642096e624c7f6ada1b5a5bc70a5d21be1
    container_name: pocketbase
    restart: unless-stopped
    # The image declares no USER, so without this line it runs as root.
    # PocketBase writes nothing outside its data directory, so uid 1000 is
    # enough, and step 2 hands that directory to 1000.
    user: "1000:1000"
    env_file: /srv/pocketbase/.env
    environment:
      # Inside the container the server has to listen on every interface, or
      # the loopback port published on the host reaches nothing. 8090 is the
      # port the image's entrypoint defaults to, named here so a change to
      # that default cannot move it under the healthcheck and the Caddy block.
      PB_HOST: "0.0.0.0"
      PB_PORT: "8090"
    volumes:
      # The one mount: data.db, every uploaded file, and PocketBase's own
      # backup archives. Local disk only: SQLite needs real POSIX file locks,
      # and a network mount corrupts it quietly.
      - /srv/pocketbase/data:/pb_data
    healthcheck:
      test: ["CMD-SHELL", "wget -q -O /dev/null http://127.0.0.1:8090/api/health || exit 1"]
      start_period: 10s
      interval: 15s
      retries: 10
    ports:
      # Loopback only. The host's Caddy is the only thing that reaches 8166,
      # and 8166 never enters the firewall.
      - "127.0.0.1:8166:8090"
```

## compose.local.yml

```yaml
# PocketBase · the deterministic fallback for the local path. Authored by
# caniselfhostit from the upstream documentation, not copied from a repository:
#   production notes ... https://pocketbase.io/docs/going-to-production/
#   health endpoint .... https://pocketbase.io/docs/api-health/
#   image entrypoint ... https://github.com/muchobien/pocketbase-docker/blob/22f36a08837f26b22a3327cb8066ad63c3362c70/entrypoint.sh
#
# One container. Paths are relative to ~/selfhost/pocketbase/, so one file
# works on macOS, Linux and Windows, and nothing off this machine reaches 8166.
#
# The PocketBase project publishes no image: upstream's production page states
# that PocketBase doesn't have an official Docker image. This file therefore
# uses ghcr.io/muchobien/pocketbase, packaged outside that project at the
# revision above, the one this digest was built from. Its Dockerfile unpacks
# upstream's release zip without checking it against the checksums.txt
# published beside it, and neither does upstream's own example Dockerfile, so
# what fixes these bytes is the digest below: step 7 asserts the binary in it
# reports 0.39.10. Read from ghcr.io 2026-08-07; amd64, arm64 and armv7.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  pocketbase:
    image: ghcr.io/muchobien/pocketbase:0.39.10@sha256:dfebd2550d6b5176d67afd3e859f9b642096e624c7f6ada1b5a5bc70a5d21be1
    container_name: pocketbase
    restart: unless-stopped
    # The image declares no USER, so without this it runs as root and what it
    # writes into ./data on Linux comes back owned by root.
    user: "1000:1000"
    env_file: ./.env
    environment:
      # It has to listen on every interface inside the container or the
      # published loopback port reaches nothing. 8090 is the entrypoint's own
      # default, named here so a change to it cannot move the port.
      PB_HOST: "0.0.0.0"
      PB_PORT: "8090"
    volumes:
      # The one mount: SQLite database, uploaded files, and PocketBase's own
      # backup archives. Keep off synced folders: SQLite wants POSIX locks.
      - ./data:/pb_data
    healthcheck:
      test: ["CMD-SHELL", "wget -q -O /dev/null http://127.0.0.1:8090/api/health || exit 1"]
      start_period: 10s
      interval: 15s
      retries: 10
    ports:
      # Loopback only. No other device on this network reaches 8166, not even
      # your own phone.
      - "127.0.0.1:8166:8090"
```

## Caddyfile

```text
# PocketBase · the Caddy site block for this service.
#
# Authored by caniselfhostit from
# https://pocketbase.io/docs/going-to-production/ and
# https://caddyserver.com/docs/caddyfile/directives/reverse_proxy
#
# Append this to /etc/caddy/Caddyfile, the Caddy that Prompt Zero installed, with
# <DOMAIN> replaced by the hostname pointed at this box. Caddy runs under systemd
# on the host. There is no Caddy container anywhere in this project.

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

	# No `encode` directive here, on purpose. PocketBase's realtime endpoint is
	# a long-lived text/event-stream, which Caddy flushes to the client
	# immediately instead of buffering, and a compressor in front of a stream
	# that carries JSON this small earns nothing.
	#
	# reverse_proxy sets X-Forwarded-For itself and ignores whatever the client
	# sent in that header, which is what makes it safe to name in PocketBase's
	# User IP proxy headers setting.
	#
	# 8166 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:8166
}
```

## install.sh

```bash
#!/usr/bin/env bash
# PocketBase · 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=api.example.com ADMIN_EMAIL=you@example.com ./install.sh
#
# Authored by caniselfhostit from the upstream documentation:
#   https://pocketbase.io/docs/
#   https://pocketbase.io/docs/going-to-production/
#   https://pocketbase.io/docs/api-health/
#   https://github.com/muchobien/pocketbase-docker/blob/22f36a08837f26b22a3327cb8066ad63c3362c70/entrypoint.sh
#   https://caddyserver.com/docs/automatic-https
#
# The PocketBase project publishes no Docker image and says so in its own
# production documentation. compose.yml pins a community build by digest; read
# the comment at the top of that file before you run this, because the trust
# decision it describes is yours, not this script's.
#
# One secret is generated here, on this machine: the password of the first
# superuser. It goes into /srv/pocketbase/.env with mode 600 and is never
# printed. ADMIN_EMAIL is a login name, not a mailbox; nothing here sends mail.
#
# NOT YET VERIFIED: no harness run has been recorded against this script.
set -euo pipefail

APP_DIR="${APP_DIR:-/srv/pocketbase}"
DOMAIN_HOST="${DOMAIN_HOST:-}"
ADMIN_EMAIL="${ADMIN_EMAIL:-}"

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. api.example.com"
[ -n "$ADMIN_EMAIL" ] || die "set ADMIN_EMAIL to the address your superuser account will be created under"
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 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 container runs as uid 1000, so its data directory belongs to 1000.
# Backups belong to the login user, who is the one copying them off the box.

sudo install -d -m 750 -o "$(id -u)" -g "$(id -g)" "$APP_DIR" "$APP_DIR/backups"
sudo install -d -m 750 -o 1000 -g 1000 "$APP_DIR/data"
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: this string is retyped into a browser login form.
# Read it later with
#   sudo grep PB_ADMIN_PASSWORD /srv/pocketbase/.env

if [ ! -f "$APP_DIR/.env" ]; then
	umask 077
	cat > "$APP_DIR/.env" <<-ENVFILE
		PB_ADMIN_EMAIL=${ADMIN_EMAIL}
		PB_ADMIN_PASSWORD=$(openssl rand -hex 24)
	ENVFILE
	chmod 600 "$APP_DIR/.env"
	umask 022
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-pocketbase"
	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 8166 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; 8166 stays closed"
	sudo ufw allow 80/tcp
	sudo ufw allow 443/tcp
	sudo ufw allow 443/udp
	sudo ufw status verbose
fi

# --- 6. Start it and prove it works ------------------------------------------
#
# The image's entrypoint runs `pocketbase superuser upsert` from the two
# variables in .env before it starts the web server, so the superuser account
# exists before the port ever answers a request.

docker compose pull
docker compose up -d

# The community image is only worth its digest if the binary inside is the one
# upstream released. Ask it.
docker compose exec -T pocketbase pocketbase --version | grep -q '0.39.10' \
	|| die "the container is not running, or the binary in it does not report 0.39.10. Check: docker compose logs --tail 40 pocketbase"

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

curl -sS "https://${DOMAIN_HOST}/api/health" | grep -q '"message":"API is healthy."' \
	|| die "/api/health answered 200 without the healthy message. Check: docker compose logs --tail 40 pocketbase"

# The collections route requires a superuser token. Upstream answers 401 to a
# request that carries none, and a 200 here would mean the API is wide open.
unauth="$(curl -sS -o /dev/null -w '%{http_code}' "https://${DOMAIN_HOST}/api/collections" || true)"
[ "$unauth" = "401" ] || die "an unauthenticated call to /api/collections returned ${unauth}, not 401. Stop and investigate."

docker compose logs pocketbase | grep -q 'Successfully saved superuser' \
	|| die "the entrypoint never created a superuser, so .env is not reaching the container. Check step 3."

# --- 7. The first backup, before day one ends --------------------------------
#
# Stopped, then copied: upstream says the application must not be running while
# pb_data is copied. The archive also carries the live Caddy site block.

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

cat <<-DONE

	PocketBase is answering at https://${DOMAIN_HOST}/api/health

	  1. Sign in at https://${DOMAIN_HOST}/_/ . The first screen is a form
	     headed "Superuser login". Your address is ${ADMIN_EMAIL} and your
	     password is in $APP_DIR/.env, mode 600. Read it with
	       sudo grep PB_ADMIN_PASSWORD $APP_DIR/.env
	     and put it in your password manager. It was not printed here.
	  2. That .env file stays the source of truth for the password. The image
	     re-applies it at every container start, so a password you change in
	     the dashboard is overwritten at the next restart. Change it in .env
	     and run: docker compose up -d --force-recreate
	  3. https://${DOMAIN_HOST}/ answers 404 until you mount something at
	     /pb_public. The API is at /api/ and the dashboard at /_/ .
	  4. First backup written to $APP_DIR/backups: the database, the uploaded
	     files, compose.yml, .env and the live /etc/caddy/Caddyfile. It is on
	     the same disk as the data, which is not a backup. Copy it off tonight:
	       scp vps:$APP_DIR/backups/*.tar.gz ~/backups/pocketbase/

DONE
```

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