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 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
- Create a project in Google Cloud Console
- APIs & Services → Credentials → Create OAuth 2.0 Client ID → Web application
- Add redirect URI:
https://<domain>:<port>/api/oauth2-redirect - 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 restartignores compose file changes. After editing volumes, ports, or env vars, usedocker compose down && docker compose up -d.restartreuses the old container config. -
pocketbase superuser upsertneeds--dirflag. Default CWD is not/pb_data. Always pass--dir=/pb_datawhen running inside the container. -
Auth API is
_superusers, notadmins. PocketBase v0.22+ usesPOST /api/collections/_superusers/auth-with-password. The old/api/admins/auth-with-passwordreturns 404. -
Users collection is
_pb_users_auth_internally. UsePATCH /api/collections/_pb_users_auth_(NOTPOST /api/collections/users). POST tousersworks 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 :8090before deploying. Map to a different host port (e.g., 8091) and proxy via nginx. -
nginx needs WebSocket headers. Without
UpgradeandConnectionheaders, PocketBase realtime silently fails. -
Docker volume persists across rebuilds.
docker compose downdoesn'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 usespb.authStore.isValidwill see a valid session and auto-authenticate as admin. Fix: in the PocketBase SDK adapter, checkmodel.collectionName === 'users'before returning a user. Reject_superusersmodel 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 validuserscollection 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 callspb.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. Useexecute_codewith Python'surllibto 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". Thefirebase-to-pocketbase-migrationskill 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.mdfor the PATCH pattern to add fields to existing collections. -
created/updatedsystem fields not present on collections created via API. When collections are created programmatically (e.g., viacreateCollectionor migration scripts), PocketBase may omit thecreatedandupdatedautodate system fields. Any query usingsort: '-created'orsort: '-updated'returns HTTP 400"Something went wrong while processing your request."— a misleading error with no field name. Diagnosis: test withsort: '-id'first. If that works, the collection is missing system fields. Fix: either add the fields via admin or usesort: '-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
/pbSDK prefix. When a React/Vite SPA uses PocketBase SDK withPB_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 returnsindex.htmlinstead 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 butverified: 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.