Files
hermes-config/skills/self-hosting/pocketbase-setup/SKILL.md
T
2026-07-12 10:17:17 -04:00

16 KiB

name, description, triggers, related_skills
name description triggers related_skills
pocketbase-setup Deploy PocketBase (Firebase alternative) on a self-hosted Linux server — Docker, nginx reverse proxy with SSL, auth providers, and port conflict resolution.
PocketBase
Firebase alternative
self-hosted auth
backend as a service
Google OAuth self-hosted
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

# 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:

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:

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:

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):

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:

# 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:

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
  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:
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)

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:

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.

# 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:

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

#!/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:

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:

    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.