initial commit
This commit is contained in:
@@ -0,0 +1,297 @@
|
||||
---
|
||||
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.
|
||||
Reference in New Issue
Block a user