Can I self-host Firebase?
YES · ONE COMMAND— setup effort 1 of 4YES — it's called PocketBase. It takes one prompt, a 512 MB VPS, and about 8 minutes. There is no like-for-like Firebase price to quote for this swap, so this page quotes none.
Why people pay for Firebase
Stated as the vendor would want it stated. A replacement you pick without knowing what the subscription actually buys is a replacement you abandon in a fortnight.
Firebase sells the whole back half of an app as something you never operate. Sign-in with the phone-number and social flows already built, a database that syncs to a phone in the middle of a tunnel and reconciles when it comes back, functions that scale from nothing to a launch day without anyone being paged, static hosting on Google's edge, and Cloud Messaging, which is the only sanctioned way onto a locked home screen and costs nothing. The free Spark tier is genuinely generous, so most people meet the bill only once something worked, and the Blaze meter is what they go looking to escape after the first month that surprised them.
| Plan | List price | What it buys |
|---|---|---|
| Spark | free | No cost and no payment method. Carries the free-tier allowances and no charges at all for Cloud Messaging, Analytics, Crashlytics, Remote Config, App Check, A/B Testing, App Distribution, In-App Messaging or Performance Monitoring. |
| Blazethe plan this page prices against | free | No subscription fee: the Spark allowances still apply and everything past them is metered. Firestore is free to 1 GiB stored, 50K document reads, 20K writes and 20K deletes a day and 10 GiB egress a month; Authentication to 50K monthly active users, with phone sign-in billed per SMS; Cloud Functions to 2M invocations a month, then $0.40 per million, plus 400K GB-seconds and 200K CPU-seconds; Hosting to 10 GB stored, then $0.026/GB, and 360 MB of transfer a day, then $0.15/GB; Cloud Storage to 5 GB-months stored and 100 GB downloaded a month; Realtime Database to 1 GB stored, then $5/GB. Eligible accounts get $300 of credit. |
Vendor list prices in USD, read from the pricing page on 2026-08-07 · confidence: medium
Replaced by PocketBase
One project, named before the prompt, so you know what you are about to install.
One Go binary that gives a side project its backend: accounts, a SQLite database with realtime subscriptions, file uploads and a REST API.
The honest swap for the side project, not for the startup. One Go binary gives you the four things most Firebase apps actually use: accounts with email and OAuth2 sign-in, a database you query over a REST-ish API with realtime subscriptions on top, file uploads, and a dashboard to administer all of it. It is one container, one SQLite file and one generated password, which is why the whole install fits in a command, and it makes the usage meter go away completely. What you give up is the part Google's infrastructure was doing: the database is one file on one disk with no replica and no failover, there is no Cloud Messaging so nothing reaches a phone that is not looking at your app, and there is no importer, so leaving Firestore means exporting your collections and writing the loader yourself. Upstream is pre-1.0 and breaks things in minor releases, which is fine for a project you read the notes for and wrong for one you cannot babysit.
The swap
You'd run
PocketBase
ONE COMMAND · ~8 min to running · 512 MB RAM
Firebase Blaze · a metered rate, not a whole bill · no like-for-like list price on the vendor's page, so this page quotes none
Before you start
- RAM floor
- 512 MBfloor from upstream docs — not measured by us yet
- Disk
- 5 GBthe app, its data, and room for one backup
- Domain needed
- yes, one A recorda hostname pointed at the box before you start — TLS needs it on the cloud path, and the local path needs none
- Time budget
- ~8 minunder 10 minutes, through the first backup
The prompt
Two paths to the same PocketBase: the cloud one assumes Prompt Zero is done on a server you rent, the local one assumes nothing but a computer that can run Docker Desktop. Read whichever you pick before you paste it, which is the whole reason both are on the page instead of behind a download.
Where it runs
318 lines · 14,725 bytes
What this prompt will do
- Preflight
- Layout
- Secrets
- compose.yml
- Caddy and TLS
- Firewall
- Start and verify
- First backup and restore
- Updating later
- What will probably go wrong
- Out of scope
Read out of the prompt’s own step headings at build time — if the prompt changes, this list changes with it.
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.No terminal agent? Use the chat fallback — slower, you paste the commands
For ChatGPT or Claude in a browser. The model cannot touch your server, so it hands you one command at a time and you run each one. Same install, more of your evening.
This path is slower: you paste every command yourself, and there is nobody watching the
output but you. If you can run Claude Code, use the other tab.
You are installing 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.317 lines · 14,905 bytes
What this prompt will do
- Preflight
- Docker
- Layout
- Secrets
- compose.yml
- Nothing is public
- Start and verify
- First backup and restore
- Updating later
- What will probably go wrong
- Out of scope
Read out of the prompt’s own step headings at build time — if the prompt changes, this list changes with it.
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.compose.local.ymlthe services, pinned · local layout48 lines
# 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"agent-readable mirror: /self-host/firebase-blaze.md
The files, if you'd rather do it yourself
The cloud path with no agent involved: three files, in the order you'd use them. The cloud prompt above writes exactly these — if the two ever disagree, the files are the ones CI diffs. The local path ships its own compose file, collapsed under its own prompt.
compose.ymlthe services, pinned56 lines
# 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"Caddyfilethe hostname and TLS32 lines
# 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.shthe same install, no agent165 lines
#!/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/
DONEWhat you're signing up for
The part a vendor's comparison page leaves out. None of it is a reason not to do this; all of it is yours the moment you cancel Firebase.
- The application is upstream's, the Docker packaging is not. PocketBase ships release binaries with a checksums.txt and no image, so this install uses ghcr.io/muchobien/pocketbase, maintained outside the PocketBase project. Its Dockerfile fetches upstream's own release zip but does not check it against that checksums.txt, and neither does the example Dockerfile in upstream's docs. Pinning the digest fixes exactly which build you run and step 7 asks the binary its version, but the packager's cadence is one more thing you are trusting.
- This is a side-project backend, and the shape of it says so. One SQLite database on one box means writes go through one file on one disk, there is no read replica and no failover, and the way you make it bigger is a bigger box. Upstream is pre-1.0 and breaking changes land in minor releases, so an upgrade is something you read about first rather than something you automate.
- No push notifications. Firebase Cloud Messaging is free on Firebase and has no equivalent here: reaching a phone that is not currently looking at your app needs Apple's and Google's push networks, and neither of them talks to your server without an account with them. If your product is notifications, this is the wrong swap.
- The superuser password lives in a file, and the file wins. The image re-applies PB_ADMIN_PASSWORD at every container start, so a password you change in the dashboard is silently restored at the next restart. That is a documented reset path and a real trap, and the fix is to treat .env as the source of truth or delete that line once you own the password.
- There is no migration path from Firestore. Nobody ships an importer, the data models do not line up, and moving means exporting your collections and writing the loader yourself. Plan this as a rebuild of the backend, not as a lift and shift.
Where this came from
“PocketBase doesn't have an official Docker image, but you could use the below minimal Dockerfile as an example.”
- PocketBase publishes no Docker image of its own and documents an example Dockerfile instead, which is why this install pins a community-built image by digest. source
- The community image's Dockerfile downloads upstream's release zip for the target architecture over HTTPS and unpacks it, without verifying it against the checksums.txt published beside that release. source
- The image's entrypoint runs pocketbase superuser upsert from PB_ADMIN_EMAIL and PB_ADMIN_PASSWORD before starting the server, on every container start, so the account exists before the port answers and the file keeps overriding the dashboard. source
- The health endpoint at /api/health needs no authentication and answers with a JSON body whose message field reads API is healthy. source
- Every route under /api/collections is bound to the superuser auth middleware, so a request carrying no superuser token is refused with 401 rather than answered. source
Questions people actually ask
Answered from this page's own data — the same numbers, in sentences.
Can I self-host Firebase?
Not Firebase itself — the vendor does not ship a version you can run on your own server. What you can self-host is the job people pay it for, and the answer to that is PocketBase. One Go binary that gives a side project its backend: accounts, a SQLite database with realtime subscriptions, file uploads and a REST API. The install is one command: one container behind Caddy with automatic TLS, secrets generated on the server rather than in a chat window, and a first backup taken before the agent says it is done, in about 8 minutes. The prompt on this page does it; the compose.yml, Caddyfile and install.sh below do the same install with no agent at all.
What replaces Firebase?
PocketBase. One Go binary that gives a side project its backend: accounts, a SQLite database with realtime subscriptions, file uploads and a REST API. The honest swap for the side project, not for the startup. One Go binary gives you the four things most Firebase apps actually use: accounts with email and OAuth2 sign-in, a database you query over a REST-ish API with realtime subscriptions on top, file uploads, and a dashboard to administer all of it. It is one container, one SQLite file and one generated password, which is why the whole install fits in a command, and it makes the usage meter go away completely. What you give up is the part Google's infrastructure was doing: the database is one file on one disk with no replica and no failover, there is no Cloud Messaging so nothing reaches a phone that is not looking at your app, and there is no importer, so leaving Firestore means exporting your collections and writing the loader yourself. Upstream is pre-1.0 and breaks things in minor releases, which is fine for a project you read the notes for and wrong for one you cannot babysit. PocketBase is MIT-licensed and free; nothing on this page is a hosted service we sell you.
What does self-hosting cost compared to Firebase?
512 MB of RAM and 5 GB of disk — the smallest tier most VPS hosts sell, about $5 a month. PocketBase itself is free and MIT-licensed; the bill is the server, plus a domain you probably already own. There is no like-for-like Firebase list price behind this swap, so this page does not invent a savings figure.
How hard is it really?
ONE COMMAND — under 10 minutes. The rule that produced that verdict: one container, no database, no outside integration, at most one secret. Nothing to negotiate with anyone else, nothing to back up separately, at most one secret to generate. This is the case where the compose file honestly is the whole install. The tier is derived from seven countable facts about the PocketBase install, not from anyone's impression of it, and the whole rubric is published on the methodology page.
Can I run PocketBase on my own computer instead of a server?
Yes — that is the second path in the prompt box above. "On my computer" installs the same PocketBase on the machine you are sitting at: no VPS, no domain, no DNS, and nothing exposed to the internet. It checks for Docker first and installs Docker Desktop if the machine does not have it — macOS, Windows and Linux each get their own step — then binds everything to loopback, so the app answers on http://localhost and only on that computer. The catch: The backend answers at http://localhost:8166 and nowhere else, so the app you build on it works in a browser on this computer while the phone you wanted to test it from cannot reach the API at all. Same discipline as the cloud path: pinned images, secrets generated on the machine, and a first backup taken before the prompt says it is done.
Content last checked 2026-08-07. Verdicts are derived from the published rubric on /methodology; corrections go through the issue tracker.