Files
mesh-configs/skills/pocketbase-deployment/SKILL.md
T

10 KiB

name, description, version, author, platforms
name description version author platforms
pocketbase-deployment PocketBase als Backend auf Linux-VPS deployen: Installation, systemd, Collection-Schema anlegen (0.25 Quirks!), Filter-Syntax, API-Integration mit Flask. Vermeidet die typischen 400/404-Fehler. 1.0.0 mehlzoerwer
linux

PocketBase Deployment (Linux VPS)

PocketBase als leichtgewichtiges Backend für Web-Projekte (PersoEnt, Weltklasse-OS). Getestet mit v0.25.0 auf Ubuntu 24.04.

Installation

cd /tmp && curl -sL https://github.com/pocketbase/pocketbase/releases/download/v0.25.0/pocketbase_0.25.0_linux_amd64.zip -o pb.zip
unzip -o pb.zip -d /opt/pocketbase
/opt/pocketbase/pocketbase --version

systemd (/etc/systemd/system/pocketbase.service):

[Unit]
Description=PocketBase
After=network.target

[Service]
User=www-data
Group=www-data
WorkingDirectory=/opt/pocketbase
ExecStart=/opt/pocketbase/pocketbase serve --http=127.0.0.1:8090
Restart=always
RestartSec=5

[Install]
WantedBy=multi-user.target
mkdir -p /opt/pocketbase/pb_data && chown -R www-data:www-data /opt/pocketbase
systemctl daemon-reload && systemctl enable pocketbase && systemctl start pocketbase
curl -s http://127.0.0.1:8090/api/health  # {"message":"API is healthy."}

Superuser + Auth-Token

# Superuser via CLI erstellen (API-Endpoint /_superusers existert NICHT zum Anlegen!)
/opt/pocketbase/pocketbase superuser upsert admin@projekt.local P4ssw0rd!

# Token holen:
TOKEN=$(curl -s -X POST http://127.0.0.1:8090/api/collections/_superusers/auth-with-password \
  -H 'Content-Type: application/json' \
  -d '{"identity":"admin@projekt.local","password":"P4ssw0rd!"}' | python3 -c "import sys,json; print(json.load(sys.stdin)['token'])")

⚠️ Token läuft lange (exp ~1 Jahr bei superuser upsert), trotzdem bei API-Code hartcodieren nur für Prototypen. Produktion: OAuth/Middleware.

🔥 Collection-Schema anlegen (0.25 QUIRKS!)

POST /api/collections mit schema-Array IGNORIERT das Schema komplett! Die Collection wird OHNE Felder erstellt (nur id). Das führt zu 400 bei jedem Filter.

Fix — Schema NACHTRÄGLICH via PATCH mit id-Feldern:

curl -s -X PATCH http://127.0.0.1:8090/api/collections/health_records \
  -H "Authorization: $TOKEN" \
  -H 'Content-Type: application/json' \
  -d '{
    "fields": [
      {"name":"weight","type":"number","required":true,"min":0,"max":500,"id":"w1","presentable":false,"system":false},
      {"name":"blood_pressure","type":"text","required":true,"min":3,"max":10,"id":"bp1","presentable":false,"system":false},
      {"name":"pulse","type":"number","required":true,"min":0,"max":300,"id":"p1","presentable":false,"system":false},
      {"name":"user_id","type":"text","required":true,"min":1,"max":100,"id":"u1","presentable":false,"system":false}
    ]
  }'

→ Antwort zeigt FIELDS: ['id', 'weight', 'blood_pressure', 'pulse', 'user_id'] ✅

Jedes Feld BRAUCHT eine id! Ohne id wird es abgelehnt.

🔥 Filter-Syntax (0.25)

Falsch Richtig Warum
filter=user_id='x' filter=user_id = "x" Leerzeichen + Doppelte Anführungszeichen
sort=-created sort=-id created ist System-Feld → 400 bei Sort. ABER sort=-id funktioniert (200): PocketBase-IDs sind chronologisch aufsteigend, -id = neueste zuerst. VERIFIZIERT (2026-08-06): sort=created und sort=-created geben 400, sort=-id und sort=weight geben 200. Für "neueste zuerst" immer sort=-id nutzen.

Beispiel (funktioniert):

GET /api/collections/health_records/records?filter=user_id%20%3D%20%22x%22&page=1&perPage=10

Flask-Integration (Backend):

import requests
POCKETBASE_URL = "http://127.0.0.1:8090"
PB_TOKEN = "<SUPERUSER_TOKEN>"  # für Filter-Abfragen nötig (viewRule leer reicht nicht!)

# WRITE (POST):
r = requests.post(f"{POCKETBASE_URL}/api/collections/health_records/records",
                  json={"weight":72,"blood_pressure":"120/80","pulse":68,"user_id":"x"})

# READ (GET mit Filter):
r = requests.get(f"{POCKETBASE_URL}/api/collections/health_records/records",
                 params={"filter": 'user_id = "x"', "page":1, "perPage":10},
                 headers={"Authorization": PB_TOKEN})

⚠️ viewRule: "" reicht NICHT für Filter-Queries — PocketBase verlangt Auth-Header bei filter=. Admin-Token im Backend-Code löst das.

🔥 Write-Schutz: leerer createRule/updateRule blockt User-Writes (400)

Symptom (2026-08-15): PersoEnt-User mit users/auth-with-password-Token kann lesen, aber POST /api/collections/health_records/records → 400 Failed to create record, selbst mit vollständigem Record, der exakt einem bestehenden entspricht.

Ursache: Die Collection hat leere createRule UND updateRule (= nur Superuser darf schreiben; normaler User darf nur bei gesetzter Regel). Das ist gutes Sicherheits-Design, aber man stolpert darüber, weil der User-Read funktioniert.

Diagnose (definitiv, ohne Ratenein):

# Collection-Regeln direkt aus der SQLite-DB lesen:
python3 -c "
import sqlite3
c=sqlite3.connect('/opt/pocketbase/pb_data/data.db')
for r in c.execute(\"SELECT name,createRule,updateRule FROM _collections WHERE name='health_records'\"):
    print('create:', repr(r[1]), '| update:', repr(r[2]))
"
# leere Regel = '' oder NULL → nur Superuser darf schreiben

Fix (Hauptweg): Für Server-Integration mit Superuser-Token schreiben (nicht User-Token). Siehe ## Superuser + Auth-Token oben — pocketbase superuser upsert ... anlegen + collections/_superusers/auth-with-password-Token verwenden. Behalte das restriktive Design bei (kein offener User-Write), statt die Regel zu lockern — sensible Gesundheitsdaten.

Fallback (Option B, verifiziert 2026-08-15): Wenn KEIN Superuser-Passwort existiert (Nutzer hat PB nicht aufgesetzt / kennt das Admin-PW nicht), kann der Server-Integration-Poller direkt in die SQLite-DB schreiben (root, läuft auf dem VPS):

# direkter INSERT in /opt/pocketbase/pb_data/data.db — umgeht API-createRule komplett
import sqlite3, shutil, datetime, os
PB_DB = "/opt/pocketbase/pb_data/data.db"
os.makedirs("/opt/pocketbase/pb_data/backups", exist_ok=True)
# Rolling-Backup (max 3) VOR erstem Write — Backup-Regel
backups = sorted(b for b in os.listdir("/opt/pocketbase/pb_data/backups") if b.endswith(".db"))
if len(backups) >= 3: os.remove(os.path.join("/opt/pocketbase/pb_data/backups", backups[0]))
shutil.copy2(PB_DB, f"/opt/pocketbase/pb_data/backups/data_{datetime.datetime.now():%Y%m%d_%H%M%S}.db")
con = sqlite3.connect(PB_DB); cur = con.cursor()
cols = [r[1] for r in cur.execute("PRAGMA table_info('health_records')")]  # vorhandene Spalten pruefen
vals = {"steps":981,"stress":22,"pulse":51,"sleep_hours":7.77,"user_id":"thorsten"}
sub = [c for c in vals if c in cols]          # NUR vorhandene Spalten senden
cur.execute(f"INSERT INTO health_records ({','.join(sub)}) VALUES ({','.join('?'*len(sub))})",
            [vals[c] for c in sub]); con.commit(); con.close()

PocketBase-Leseseite: direkte Writes sind via API lesbar (sort=-id), weil PB die SQLite-DB direkt liest — aber PB-Validierung/Trigger werden umgangen. Nur nutzen für einfache, single-User-Inert-Tables (Cron-Poller auf health_records); nicht für Beziehungen/Migrationen/parallele PB-Writes. Vor dem ersten Write DOM-typ trotzdem: Collection-Spalten via PRAGMA table_info holen (kein datum/source-Feld annehmen — es kann fehlen). NICHTS erfinden, echte COL-Check.

🔥 Schema-Cache-Gotcha: per ALTER TABLE ergänzte Spalte wird NICHT von der API geliefert

Symptom (2026-08-15): Du fügst per direktem ALTER TABLE health_records ADD COLUMN datum TEXT eine neue Spalte hinzu und schreibst sie per Direkt-Write — aber die API liefert datum nicht (Record-Keys ohne das Feld), obwohl es in der SQLite-Tabelle existiert.

Ursache: PocketBase hält sein Collection-Schema in _collections.fields (separate JSON) + im RAM-Cache. Ein rohes ALTER TABLE ändert die Tabelle, aber nicht das PB-Schema → API white-/blacklistet das Feld nicht.

Fix (verifiziert 2026-08-15): Feld NACHträglich in PB-Schema eintragen UND Service neu laden:

# 1. Backup (Backup-Regel) — cp data.db data.db.bak-<ts>
# 2. Feld in _collections.fields (JSON) eintragen:
python3 -c "
import sqlite3, json
c=sqlite3.connect('/opt/pocketbase/pb_data/data.db')
row=c.execute(\"SELECT fields FROM _collections WHERE name='health_records'\").fetchone()
f=json.loads(row[0])
if 'datum' not in [x.get('name') for x in f]:
    f.append({'name':'datum','type':'text','required':False,'system':False,'max':10,'min':0,'pattern':'','autogeneratePattern':'','primaryKey':False})
    c.execute(\"UPDATE _collections SET fields=? WHERE name='health_records'\",(json.dumps(f),)); c.commit()
# 3. PocketBase-Service neu laden (RAM-Schema-Cache!):
"
systemctl restart pocketbase   # ODER systemctl restart pocketbase.service
# Verifikation: API-Response-keys enthalten jetzt das Feld

Ohne den systemctl restart pocketbase liefert die API das neue Feld NICHT — der RAM-Cache nimmt Schema-Änderungen erst nach Neustart an. Danach: GET .../records?filter=...&sort=-id → datum taucht in Record-Keys auf.

⌚ Wearable-/Geräte-Daten einspeisen (COROS-MCP-Beispiel)

Flask + Nginx Reverse-Proxy

Backend unter /var/www/<projekt>/backend/app.py (systemd persoent-api, siehe references/flask-deploy.md). Nginx:

location /api/ {
    proxy_pass http://127.0.0.1:5000/api/;
    proxy_set_header Host $host;
    proxy_set_header X-Real-IP $remote_addr;
}

Verification

# Write test:
curl -s -X POST https://<domain>/api/health/record -H 'Content-Type: application/json' \
  -d '{"weight":72,"blood_pressure":"120/80","pulse":68,"user_id":"test"}'
# → {"id":"...","message":"Health record saved successfully"}

# Read test:
curl -s "https://<domain>/api/health/records?user_id=test"
# → {"records":[...],"total":1}

Referenzen

  • references/flask-deploy.md — Flask-App als systemd-Service + Nginx Proxy (PersoEnt-Pattern)
  • references/pocketbase-quirks-19-07-2026.md — Session-Transcript der 400-Fehler + Fixes
  • references/coros-mcp-wearable-integration.md — COROS-Sportuhr-Daten via offiziellem coros-mcp CLI in health_records (Login-Flow, korrekte --tool/--arguments-json-Syntax, Text-Block-Parsing) für Wearable-/Geräte-Einspeisung