Add self-hosted PocketBase stack with auto-migrations

- docker-compose.yml: app + pocketbase services (PB internal to the network)
- pocketbase/: pinned PocketBase image, superuser bootstrap via env vars
- pb_migrations: production schema (collections, rules, public_users view)
  applied automatically on first boot
- pocketbase/pb_schema.json: schema export kept as reference
- README: full-stack deployment docs
This commit is contained in:
2026-07-28 09:38:07 +02:00
parent c3c1f5e216
commit 3d712b8c57
6 changed files with 550 additions and 0 deletions
+19
View File
@@ -0,0 +1,19 @@
# PocketBase backend with auto-applied migrations (collections + rules).
# Migrations in ./pb_migrations run automatically when the server starts.
FROM alpine:3.21
ARG PB_VERSION=0.39.9
RUN apk add --no-cache ca-certificates curl unzip
WORKDIR /pb
RUN curl -fsSL "https://github.com/pocketbase/pocketbase/releases/download/v${PB_VERSION}/pocketbase_${PB_VERSION}_linux_amd64.zip" -o pb.zip \
&& unzip pb.zip \
&& rm pb.zip
COPY pb_migrations ./pb_migrations
COPY entrypoint.sh ./
RUN chmod +x entrypoint.sh pocketbase
EXPOSE 8090
CMD ["./entrypoint.sh"]
+12
View File
@@ -0,0 +1,12 @@
#!/bin/sh
set -e
# Optionally create/update the admin (superuser) account on boot.
# Set PB_SUPERUSER_EMAIL and PB_SUPERUSER_PASSWORD as env vars to enable.
# The password must be at least 8 characters.
if [ -n "$PB_SUPERUSER_EMAIL" ] && [ -n "$PB_SUPERUSER_PASSWORD" ]; then
./pocketbase superuser upsert "$PB_SUPERUSER_EMAIL" "$PB_SUPERUSER_PASSWORD" || true
fi
# `serve` automatically applies pending migrations from ./pb_migrations
exec ./pocketbase serve --http=0.0.0.0:8090
+119
View File
@@ -0,0 +1,119 @@
/**
* Initial PokeShare schema: extends the default `users` auth collection and
* creates `public_users`, `mycards` and `friendships` with their API rules.
* Mirrors the production schema (pb_schema.json export).
* Applied automatically by `pocketbase serve` on first boot.
*/
migrate((app) => {
// --- users (auth collection): extra profile fields, self-only access ---
const users = app.findCollectionByNameOrId("users");
users.fields.add(
new Text({ name: "username", required: true, min: 3, max: 20 }),
new Select({
name: "theme_preference",
values: ["System", "Light", "Dark"],
maxSelect: 1,
}),
new Select({
name: "visibility",
values: ["Public", "Friends", "Private"],
maxSelect: 1,
}),
);
users.indexes.push(
"CREATE UNIQUE INDEX idx_users_username ON users (username COLLATE NOCASE)",
);
users.listRule = "id = @request.auth.id";
users.viewRule = "id = @request.auth.id";
users.updateRule = "id = @request.auth.id";
// No public registration — accounts are created from the admin UI (/_/)
users.createRule = null;
app.save(users);
// --- public_users: read-only view used by the friend search ---
const publicUsers = new Collection({
name: "public_users",
type: "view",
viewQuery: `SELECT id, username, name, visibility FROM users WHERE visibility != 'Private'`,
listRule: '@request.auth.id != ""',
viewRule: null,
});
app.save(publicUsers);
// --- mycards: one row per owned card ---
// listRule enforces collection visibility at the database level:
// owner, or anyone if the owner is Public, or accepted friends if Friends.
const mycards = new Collection({
name: "mycards",
type: "base",
fields: [
{ name: "card_id", type: "text" },
{ name: "card_name", type: "text" },
{
name: "user",
type: "relation",
collectionId: "_pb_users_auth_",
maxSelect: 1,
cascadeDelete: true,
},
{ name: "card_image", type: "url" },
{ name: "variant", type: "text" },
{ name: "quantity", type: "number" },
{ name: "set_name", type: "text" },
{ name: "notes", type: "text" },
{ name: "condition", type: "text" },
],
listRule:
"@request.auth.id = user || (user.visibility = 'Public') || (user.visibility = 'Friends' && ((@collection.friendships.requester = user && @collection.friendships.addressee = @request.auth.id && @collection.friendships.status = 'accepted') || (@collection.friendships.addressee = user && @collection.friendships.requester = @request.auth.id && @collection.friendships.status = 'accepted')))",
viewRule:
"@request.auth.id = user || (@collection.friendships.requester = user && @collection.friendships.addressee = @request.auth.id && @collection.friendships.status = 'accepted') || (@collection.friendships.addressee = user && @collection.friendships.requester = @request.auth.id && @collection.friendships.status = 'accepted')",
createRule: '@request.auth.id != "" && user = @request.auth.id',
updateRule: '@request.auth.id != "" && user = @request.auth.id',
deleteRule: '@request.auth.id != "" && user = @request.auth.id',
});
app.save(mycards);
// --- friendships ---
const friendships = new Collection({
name: "friendships",
type: "base",
fields: [
{
name: "requester",
type: "relation",
collectionId: "_pb_users_auth_",
maxSelect: 1,
cascadeDelete: true,
},
{
name: "addressee",
type: "relation",
collectionId: "_pb_users_auth_",
maxSelect: 1,
cascadeDelete: true,
},
{
name: "status",
type: "select",
values: ["accepted", "pending", "declined"],
maxSelect: 1,
},
],
listRule: "@request.auth.id = requester || @request.auth.id = addressee",
viewRule: "@request.auth.id = requester || @request.auth.id = addressee",
createRule: "@request.auth.id = requester && addressee != requester",
updateRule: "@request.auth.id = addressee || @request.auth.id = requester",
deleteRule: "@request.auth.id = addressee || @request.auth.id = requester",
});
app.save(friendships);
}, (app) => {
// Down: drop the custom collections. The added `users` fields are left in
// place — removing them would destroy data and they are harmless.
for (const name of ["friendships", "mycards", "public_users"]) {
try {
app.delete(app.findCollectionByNameOrId(name));
} catch {
// already missing
}
}
});
+349
View File
@@ -0,0 +1,349 @@
[
{
"id": "pbc_274306238",
"listRule": "@request.auth.id = requester || @request.auth.id = addressee",
"viewRule": "@request.auth.id = requester || @request.auth.id = addressee",
"createRule": "@request.auth.id = requester && addressee != requester",
"updateRule": "@request.auth.id = addressee || @request.auth.id = requester",
"deleteRule": "@request.auth.id = addressee || @request.auth.id = requester",
"name": "friendships",
"type": "base",
"fields": [
{
"autogeneratePattern": "[a-z0-9]{15}",
"help": "",
"hidden": false,
"id": "text3208210256",
"max": 15,
"min": 15,
"name": "id",
"pattern": "^[a-z0-9]+$",
"presentable": false,
"primaryKey": true,
"required": true,
"system": true,
"type": "text"
},
{
"cascadeDelete": false,
"collectionId": "_pb_users_auth_",
"help": "",
"hidden": false,
"id": "relation1820765950",
"maxSelect": 0,
"minSelect": 0,
"name": "requester",
"presentable": false,
"required": false,
"system": false,
"type": "relation"
},
{
"cascadeDelete": false,
"collectionId": "_pb_users_auth_",
"help": "",
"hidden": false,
"id": "relation2602483783",
"maxSelect": 0,
"minSelect": 0,
"name": "addressee",
"presentable": false,
"required": false,
"system": false,
"type": "relation"
},
{
"help": "",
"hidden": false,
"id": "select2063623452",
"maxSelect": 0,
"name": "status",
"presentable": false,
"required": false,
"system": false,
"type": "select",
"values": [
"accepted",
"pending",
"declined"
]
},
{
"hidden": false,
"id": "autodate2990389176",
"name": "created",
"onCreate": true,
"onUpdate": false,
"presentable": false,
"system": false,
"type": "autodate"
},
{
"hidden": false,
"id": "autodate3332085495",
"name": "updated",
"onCreate": true,
"onUpdate": true,
"presentable": false,
"system": false,
"type": "autodate"
}
],
"indexes": [],
"system": false
},
{
"id": "pbc_143883864",
"listRule": "@request.auth.id = user || (user.visibility = 'Public') || (user.visibility = 'Friends' && ((@collection.friendships.requester = user && @collection.friendships.addressee = @request.auth.id && @collection.friendships.status = 'accepted') || (@collection.friendships.addressee = user && @collection.friendships.requester = @request.auth.id && @collection.friendships.status = 'accepted')))",
"viewRule": "@request.auth.id = user || (@collection.friendships.requester = user && @collection.friendships.addressee = @request.auth.id && @collection.friendships.status = 'accepted') || (@collection.friendships.addressee = user && @collection.friendships.requester = @request.auth.id && @collection.friendships.status = 'accepted')",
"createRule": "@request.auth.id != \"\" && user = @request.auth.id",
"updateRule": "@request.auth.id != \"\" && user = @request.auth.id",
"deleteRule": "@request.auth.id != \"\" && user = @request.auth.id",
"name": "mycards",
"type": "base",
"fields": [
{
"autogeneratePattern": "[a-z0-9]{15}",
"help": "",
"hidden": false,
"id": "text3208210256",
"max": 15,
"min": 15,
"name": "id",
"pattern": "^[a-z0-9]+$",
"presentable": false,
"primaryKey": true,
"required": true,
"system": true,
"type": "text"
},
{
"autogeneratePattern": "",
"help": "",
"hidden": false,
"id": "text1254922784",
"max": 0,
"min": 0,
"name": "card_id",
"pattern": "",
"presentable": false,
"primaryKey": false,
"required": false,
"system": false,
"type": "text"
},
{
"autogeneratePattern": "",
"help": "",
"hidden": false,
"id": "text2987424932",
"max": 0,
"min": 0,
"name": "card_name",
"pattern": "",
"presentable": false,
"primaryKey": false,
"required": false,
"system": false,
"type": "text"
},
{
"cascadeDelete": false,
"collectionId": "_pb_users_auth_",
"help": "",
"hidden": false,
"id": "relation1653163849",
"maxSelect": 0,
"minSelect": 0,
"name": "user",
"presentable": false,
"required": false,
"system": false,
"type": "relation"
},
{
"exceptDomains": null,
"help": "",
"hidden": false,
"id": "url4245288345",
"name": "card_image",
"onlyDomains": null,
"presentable": false,
"required": false,
"system": false,
"type": "url"
},
{
"autogeneratePattern": "",
"help": "",
"hidden": false,
"id": "text4047749037",
"max": 0,
"min": 0,
"name": "variant",
"pattern": "",
"presentable": false,
"primaryKey": false,
"required": false,
"system": false,
"type": "text"
},
{
"help": "",
"hidden": false,
"id": "number2683508278",
"max": null,
"min": null,
"name": "quantity",
"onlyInt": false,
"presentable": false,
"required": false,
"system": false,
"type": "number"
},
{
"autogeneratePattern": "",
"help": "",
"hidden": false,
"id": "text3420757135",
"max": 0,
"min": 0,
"name": "set_name",
"pattern": "",
"presentable": false,
"primaryKey": false,
"required": false,
"system": false,
"type": "text"
},
{
"autogeneratePattern": "",
"help": "",
"hidden": false,
"id": "text18589324",
"max": 0,
"min": 0,
"name": "notes",
"pattern": "",
"presentable": false,
"primaryKey": false,
"required": false,
"system": false,
"type": "text"
},
{
"autogeneratePattern": "",
"help": "",
"hidden": false,
"id": "text849699619",
"max": 0,
"min": 0,
"name": "condition",
"pattern": "",
"presentable": false,
"primaryKey": false,
"required": false,
"system": false,
"type": "text"
},
{
"hidden": false,
"id": "autodate2990389176",
"name": "created",
"onCreate": true,
"onUpdate": false,
"presentable": false,
"system": false,
"type": "autodate"
},
{
"hidden": false,
"id": "autodate3332085495",
"name": "updated",
"onCreate": true,
"onUpdate": true,
"presentable": false,
"system": false,
"type": "autodate"
}
],
"indexes": [],
"system": false
},
{
"id": "pbc_83919930",
"listRule": "@request.auth.id != \"\"",
"viewRule": null,
"createRule": null,
"updateRule": null,
"deleteRule": null,
"name": "public_users",
"type": "view",
"fields": [
{
"autogeneratePattern": "",
"help": "",
"hidden": false,
"id": "text3208210256",
"max": 0,
"min": 0,
"name": "id",
"pattern": "^[a-z0-9]+$",
"presentable": false,
"primaryKey": true,
"required": true,
"system": true,
"type": "text"
},
{
"autogeneratePattern": "",
"help": "",
"hidden": false,
"id": "_clone_EOl9",
"max": 20,
"min": 3,
"name": "username",
"pattern": "",
"presentable": false,
"primaryKey": false,
"required": false,
"system": false,
"type": "text"
},
{
"autogeneratePattern": "",
"help": "",
"hidden": false,
"id": "_clone_0z4L",
"max": 255,
"min": 0,
"name": "name",
"pattern": "",
"presentable": false,
"primaryKey": false,
"required": false,
"system": false,
"type": "text"
},
{
"help": "",
"hidden": false,
"id": "_clone_3D0a",
"maxSelect": 0,
"name": "visibility",
"presentable": false,
"required": false,
"system": false,
"type": "select",
"values": [
"Public",
"Friends",
"Private"
]
}
],
"indexes": [],
"system": false,
"viewQuery": "SELECT id, username, name, visibility FROM users WHERE visibility != 'Private'"
}
]