--- 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://:/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 ::127.0.0.1 https://:/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.