---
name: pocketbase-setup
description: Deploy PocketBase (Firebase alternative) on a self-hosted Linux server — Docker, nginx reverse proxy with SSL, auth providers, and port conflict resolution.
triggers:
  - "PocketBase"
  - "Firebase alternative"
  - "self-hosted auth"
  - "backend as a service"
  - "Google OAuth self-hosted"
related_skills:
  - firebase-to-pocketbase-migration
---

# PocketBase Self-Hosted Deployment

PocketBase is a single-binary Firebase alternative — auth (email/password + OAuth2), database, file storage, and realtime subscriptions. Deploy it in Docker behind nginx with existing SSL certs.

## Docker Setup

```yaml
# docker-compose.yml
services:
  pocketbase:
    image: ghcr.io/muchobien/pocketbase:latest
    container_name: pocketbase
    restart: unless-stopped
    ports:
      - "127.0.0.1:8091:8090"
    volumes:
      - ./pb_data:/pb_data       # ⚠️ /pb_data, NOT /pb/pb_data — entrypoint uses --dir=/pb_data
      - ./pb_public:/pb_public   # ⚠️ /pb_public, NOT /usr/local/bin/pb_public — entrypoint uses --publicDir=/pb_public
    healthcheck:
      test: wget --no-verbose --tries=1 --spider http://localhost:8090/api/health || exit 1
      interval: 30s
      timeout: 10s
      retries: 3
```

**Mount path pitfall:** The entrypoint command is `pocketbase serve --dir=/pb_data --publicDir=/pb_public`. Mounting to `/pb/pb_data` or `/usr/local/bin/pb_public` (the binary's default) silently fails — PocketBase writes to an empty volume that survives `down`, and superusers/auth look like they vanished.

**Restart doesn't pick up compose changes:** After editing `docker-compose.yml`, `docker compose restart` reuses the old container config. Always use `docker compose down && docker compose up -d` to pick up new volumes, ports, or env vars.

**Port conflict pitfall:** Choose an INTERNAL host port carefully. On our server, port 8090 was taken by Pi-hole (`0.0.0.0:8090->80/tcp`). Mapping `127.0.0.1:8091:8090` avoids the conflict — nginx proxies to `:8091`, PocketBase listens on `:8090` internally. Always check with `ss -tlnp | grep :8090` before deploying.

## nginx Reverse Proxy (with WebSocket)

PocketBase needs WebSocket support for realtime subscriptions. Use an unused SSL port:

```nginx
server {
    listen 3447 ssl;
    server_name grajmedia.duckdns.org;

    ssl_certificate /etc/letsencrypt/live/grajmedia.duckdns.org/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/grajmedia.duckdns.org/privkey.pem;
    ssl_protocols TLSv1.2 TLSv1.3;

    location / {
        proxy_pass http://127.0.0.1:8091;
        proxy_http_version 1.1;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection "upgrade";
        proxy_read_timeout 86400;
    }

    client_max_body_size 50m;
}
```

Symlink into `sites-enabled/`, run `nginx -t && systemctl reload nginx`.

**Port conflict pitfall:** nginx binds to the SSL port on 0.0.0.0. Docker must NOT also expose that same port (even on 127.0.0.1) — nginx will fail to bind with "Address already in use". Docker gets an internal port (e.g., 8091), nginx owns the SSL port (e.g., 3447).

## Superuser Password Recovery

If the superuser password is lost and admin API authentication fails with `"Failed to authenticate."` (HTTP 400), the password can be reset directly via the SQLite database:

```bash
cd /path/to/pocketbase
docker compose stop pocketbase

# Remove stale WAL/SHM files and make DB writable
rm -f pb_data/data.db-shm pb_data/data.db-wal
chmod 666 pb_data/data.db

# Generate a bcrypt hash for the new password
python3 -c "
import bcrypt, sqlite3
hashed = bcrypt.hashpw(b'NewPassword123!', bcrypt.gensalt(rounds=10)).decode()
conn = sqlite3.connect('pb_data/data.db')
conn.execute('UPDATE _superusers SET password=? WHERE email=?', (hashed, 'admin@example.com'))
conn.commit()
conn.close()
print('Password updated')
"

# Restore permissions
chmod 644 pb_data/data.db

# Start PocketBase
docker compose start pocketbase
```

**Note:** Python's `bcrypt` library uses the `$2b$` prefix while PocketBase generates `$2a$` — both are accepted by PocketBase v0.23+.

After recovery, verify:
```bash
curl -s -X POST http://127.0.0.1:8091/api/collections/_superusers/auth-with-password \
  -H 'Content-Type: application/json' \
  -d '{"identity":"admin@example.com","password":"NewPassword123!"}' \
  | python3 -c "import sys,json; d=json.load(sys.stdin); print('Token OK' if 'token' in d else 'FAILED')"
```

Create the admin account via CLI (faster than the UI):

```bash
docker exec pocketbase /usr/local/bin/pocketbase superuser upsert admin@example.com 'Str0ngPass!' --dir=/pb_data
```

The `--dir=/pb_data` flag is **critical** — without it, the `upsert` command writes to a different default directory and auth against the running server fails silently.

After admin is created, authenticate programmatically via:
```bash
# v0.22+ endpoint — NOT /api/admins/auth-with-password
curl -s -X POST http://127.0.0.1:8091/api/collections/_superusers/auth-with-password \
  -H 'Content-Type: application/json' \
  -d '{"identity":"admin@example.com","password":"..."}'
```

The old `/api/admins/auth-with-password` endpoint was removed in PocketBase v0.22+.

## Auth Provider Configuration

The `users` auth collection uses the internal system ID `_pb_users_auth_` — NOT the name `users`. All updates use `PATCH`, not `POST`.

### Email/Password (built-in)

Enable via API:
```bash
TOKEN=*** -s -X POST http://127.0.0.1:8091/api/collections/_superusers/auth-with-password \
  -H 'Content-Type: application/json' \
  -d '{"identity":"admin@example.com","password":"..."}' | python3 -c "import sys,json; print(json.load(sys.stdin)['token'])")

curl -s -X PATCH http://127.0.0.1:8091/api/collections/_pb_users_auth_ \
  -H "Authorization: $TOKEN" \
  -H 'Content-Type: application/json' \
  -d '{"passwordAuth":{"enabled":true,"minLength":8,"identityFields":["email"]}}'
```

### Google OAuth

1. Create a project in [Google Cloud Console](https://console.cloud.google.com/)
2. APIs & Services → Credentials → Create OAuth 2.0 Client ID → Web application
3. Add redirect URI: `https://<domain>:<port>/api/oauth2-redirect`
4. Enable via API:

```bash
curl -s -X PATCH http://127.0.0.1:8091/api/collections/_pb_users_auth_ \
  -H "Authorization: $TOKEN" \
  -H 'Content-Type: application/json' \
  -d '{"oauth2":{"enabled":true,"providers":{"google":{"enabled":true,"clientId":"YOUR_ID","clientSecret":"YOUR_SECRET"}}}}'
```

PocketBase handles the OAuth flow itself. No custom callback page needed — the SDK's `authWithOAuth2()` works after the provider is configured.

### Collection rules (API access control)

```bash
curl -s -X PATCH http://127.0.0.1:8091/api/collections/_pb_users_auth_ \
  -H "Authorization: $TOKEN" \
  -H 'Content-Type: application/json' \
  -d '{
    "listRule":"","viewRule":"","createRule":"",
    "updateRule":"id = @request.auth.id",
    "deleteRule":"id = @request.auth.id"
  }'
```

## Verification

### Firebase Migration

If you're migrating an existing Firebase app, see the `firebase-to-pocketbase-migration` skill — it provides a Firebase-compatible adapter (`pocketbase.js`) that lets you swap one import per file without rewriting your app, plus a same-origin nginx config to eliminate CORS.

### Same-Origin App Hosting

When deploying a JavaScript app (Flutter Web, React, vanilla JS) that uses PocketBase, the cleanest approach is same-origin nginx: serve the static app AND proxy `/api/` to PocketBase from the same server block. The app calls `new PocketBase(window.location.origin)` and all API calls go to `/api/collections/...` on the same port — zero CORS configuration needed.

Use `templates/same-origin-nginx.conf` from `firebase-to-pocketbase-migration` as the template. Replace the generic PocketBase proxy with:
```nginx
root /path/to/your/app;
index index.html;

location / { try_files $uri $uri/ /index.html; }
location /api/ { proxy_pass http://127.0.0.1:8091; }
location /_/ { proxy_pass http://127.0.0.1:8091; }
```


### Static login page
A ready-to-use login page with Google OAuth + email/password is at `templates/login.html`. Copy it to `pb_public/index.html` — PocketBase serves it at the root `/`. It uses the PocketBase JS SDK from CDN and initializes with `new PocketBase(window.location.origin)`. No build step needed.

```bash
# Health check
curl -s http://127.0.0.1:8091/api/health

# Via nginx (external URL)
curl -sk --resolve <domain>:<port>:127.0.0.1 https://<domain>:<port>/api/health
```

## Production Hardening

### Log Rotation (Docker json-file driver)

By default the PB container uses `json-file` logs with no size cap. Add a `logging` block to `docker-compose.yml`:

```yaml
logging:
  driver: json-file
  options:
    max-size: "10m"
    max-file: "5"
```

Then recreate: `docker compose up -d --force-recreate`. Verify: `docker inspect pocketbase --format '{{.HostConfig.LogConfig.Type}} {{json .HostConfig.LogConfig.Config}}'`.

**Pitfall:** `docker update --log-driver` does NOT support changing the log driver on a running container. You must recreate via `docker compose up -d --force-recreate`.

### Daily DB Backup Cron

```bash
#!/bin/bash
# /home/ray/docker/pocketbase/backup.sh
PB_CID=$(docker ps --filter "name=pocketbase" --format "{{.ID}}")
STAMP=$(date +%Y%m%d)
docker exec "$PB_CID" cp /pb_data/data.db "/pb_data/data.db.backup-$STAMP"
docker cp "$PB_CID:/pb_data/data.db.backup-$STAMP" "/backups/spq/"
docker exec "$PB_CID" rm "/pb_data/data.db.backup-$STAMP"
find /backups/spq/ -name "data.db.backup-*" -mtime +14 -delete
```

Crontab (root): `0 3 * * * /home/ray/docker/pocketbase/backup.sh >> /var/log/spq/backup.log 2>&1`

The script dynamically finds the container ID (it changes on recreate), copies the DB to `/backups/spq/`, and purges backups older than 14 days.

### Structured nginx Access Logs (JSON)

Add a `log_format` to the `http {}` block in `/etc/nginx/nginx.conf`:

```nginx
log_format json_combined escape=json '{ "time":"$time_iso8601", "remote":"$remote_addr", "method":"$request_method", "uri":"$uri", "status":$status, "bytes":$body_bytes_sent, "rt":$request_time }';
```

Then in the site config: `access_log /var/log/spq/spq.access.log json_combined;`

## Pitfalls

- **Mount paths must match entrypoint flags.** The image runs `serve --dir=/pb_data --publicDir=/pb_public`. Mount to `/pb_data` (not `/pb/pb_data`) and `/pb_public` (not `/usr/local/bin/pb_public`). Wrong paths cause silent data loss — database writes go to an empty volume.
- **`docker compose restart` ignores compose file changes.** After editing volumes, ports, or env vars, use `docker compose down && docker compose up -d`. `restart` reuses the old container config.
- **`pocketbase superuser upsert` needs `--dir` flag.** Default CWD is not `/pb_data`. Always pass `--dir=/pb_data` when running inside the container.
- **Auth API is `_superusers`, not `admins`.** PocketBase v0.22+ uses `POST /api/collections/_superusers/auth-with-password`. The old `/api/admins/auth-with-password` returns 404.
- **Users collection is `_pb_users_auth_` internally.** Use `PATCH /api/collections/_pb_users_auth_` (NOT `POST /api/collections/users`). POST to `users` works for initial creation; subsequent config changes need PATCH to the system ID.
- **Port conflicts are easy to miss.** Pi-hole often uses 8090 (host:container 80 mapping). Always `ss -tlnp | grep :8090` before deploying. Map to a different host port (e.g., 8091) and proxy via nginx.
- **nginx needs WebSocket headers.** Without `Upgrade` and `Connection` headers, PocketBase realtime silently fails.
- **Docker volume persists across rebuilds.** `docker compose down` doesn't remove `./pb_data/`. Safe to rebuild; admin account survives.
- **Admin auth leaks into same-origin apps.** Logging into the PocketBase Admin UI (`/_/`) stores the superuser token in localStorage for the origin. Any JavaScript app on the same origin that uses `pb.authStore.isValid` will see a valid session and auto-authenticate as admin. Fix: in the PocketBase SDK adapter, check `model.collectionName === 'users'` before returning a user. Reject `_superusers` model types. This prevents admin sessions from bypassing the app's login page.

- **Cached user tokens also bypass login.** After fixing the admin auth leak, the login page may still be skipped. The PocketBase SDK auto-restores ANY valid token from localStorage at initialization — including tokens from a previous `test@` or real user login. The adapter's admin filtering only rejects `_superusers`; a valid `users` collection token passes right through. When the user reports the login page not appearing after the admin fix, they likely have a stale user token. Fix: instruct them to clear site data (DevTools → Application → Clear storage), or add a manual sign-out that calls `pb.authStore.clear()`. Incognito mode is a quick test.
- **Shell token redaction.** When using `TOKEN=*** ... | python3 ...)` in bash, the token value may be redacted as `***` by secret filtering. Use `execute_code` with Python's `urllib` to pass tokens between API calls safely.

- **Collection schema API key changed in v0.23+.** PocketBase v0.22 and earlier used `"schema"` as the key for field definitions in API calls. v0.23+ changed this to `"fields"`. When scripting collection creation, check your version first: `docker exec pocketbase /usr/local/bin/pocketbase --version`. If ≥0.23, use `"fields"` not `"schema"`. The `firebase-to-pocketbase-migration` skill covers both formats.

- **PocketBase silently drops undefined fields.** Creating/updating records with fields not defined in the collection schema returns HTTP 200 but stores nothing for those fields — no error, no warning. Define all fields before any CRUD operations. See `references/add-fields-to-collection.md` for the PATCH pattern to add fields to existing collections.

- **`created`/`updated` system fields not present on collections created via API.** When collections are created programmatically (e.g., via `createCollection` or migration scripts), PocketBase may omit the `created` and `updated` autodate system fields. Any query using `sort: '-created'` or `sort: '-updated'` returns HTTP 400 `"Something went wrong while processing your request."` — a misleading error with no field name. **Diagnosis:** test with `sort: '-id'` first. If that works, the collection is missing system fields. **Fix:** either add the fields via admin or use `sort: '-id'` which is always available. Check ALL pages that query the collection — Dashboard, FinancialDashboard, RepairOrders, Invoices were all affected in one project by this single missing field.

- **SPA same-origin nginx with `/pb` SDK prefix.** When a React/Vite SPA uses PocketBase SDK with `PB_URL = '/pb'` (common default), the nginx config must handle BOTH SPA fallback AND the `/pb/` proxy. Standard pattern:
  ```nginx
  root /path/to/spa/dist;
  index index.html;

  # SPA — all client-side routes serve index.html
  location / { try_files $uri $uri/ /index.html; }

  # PocketBase SDK proxy — SDK calls /pb/api/collections/...
  location /pb/ { proxy_pass http://127.0.0.1:8091/; }

  # Also keep direct /api/ proxy for non-SDK clients
  location /api/ { proxy_pass http://127.0.0.1:8091/api/; }
  ```
  Without the `/pb/` location, PocketBase SDK calls fail because the SPA fallback returns `index.html` instead of proxying to PocketBase.

- **User sign-up via PocketBase SDK.** The `pb.collection('users').create()` method accepts `{email, password, passwordConfirm, name, emailVisibility: true}`. PocketBase queues a verification email automatically when SMTP is configured in admin. Without SMTP, accounts are created but `verified: false` — users can still log in if the collection's auth rules allow it. Test sign-up through the nginx proxy, not just direct API calls — the `/pb/` location must be configured for the SDK to reach PocketBase.
