Add production Docker compose + on-startup PocketBase bootstrap

Introduce registry-pull production stacks (docker-compose.prod.yml) for
both the multi-container Docker setup and the all-in-one Docker AIO image,
with everything an operator needs (superuser, super-admin, ports, volumes)
driven from .env.

The API Server now bootstraps PocketBase on startup: a new internal/bootstrap
package (Go port of setup-pocketbase.mjs) creates missing collections,
reconciles existing ones, and creates the DriverVault super-admin from
DRIVERVAULT_SUPERADMIN_* when absent. Idempotent and gated by PB_BOOTSTRAP.
The PocketBase superuser is still upserted by the PocketBase container, since
the REST API cannot bootstrap the first superuser.

Move PocketBase to port 8070 (internal + published) and the web app to 8090
across both stacks, with matching CORS defaults.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
tajniak81
2026-07-19 15:35:33 +02:00
co-authored by Claude Opus 4.8
parent 1e76c2b7f9
commit 30c9cdebe9
17 changed files with 1042 additions and 23 deletions
+9
View File
@@ -288,6 +288,15 @@ The Node scripts read `POCKETBASE_URL` / `POCKETBASE_ADMIN_EMAIL` /
> adding a new car spec field, extend `DESIRED.cars` in `setup-pocketbase.mjs`, > adding a new car spec field, extend `DESIRED.cars` in `setup-pocketbase.mjs`,
> add it to `models.Car` + the record mapping in `records.go`, then rebuild. > add it to `models.Car` + the record mapping in `records.go`, then rebuild.
> **Startup bootstrap:** the server also runs this same create/reconcile on boot
> (`internal/bootstrap`, a Go mirror of `setup-pocketbase.mjs`) whenever a service
> account is configured, so the Docker prod stack needs no manual setup step. It
> is idempotent and gated by `PB_BOOTSTRAP` (default `true`; set to `false` to
> skip). With `DRIVERVAULT_SUPERADMIN_EMAIL` + `DRIVERVAULT_SUPERADMIN_PASSWORD`
> set it also creates the first `superadmin` user when absent. **Keep the two
> schemas in sync:** a change to `DESIRED` in the script must be mirrored in
> `internal/bootstrap/schema.go` (guarded by `TestSchemaConsistency`).
## First-time setup ## First-time setup
1. **Create the PocketBase collections** (idempotent — safe to re-run on an 1. **Create the PocketBase collections** (idempotent — safe to re-run on an
+21
View File
@@ -15,6 +15,7 @@ import (
"time" "time"
"drivervault/apiserver/internal/api" "drivervault/apiserver/internal/api"
"drivervault/apiserver/internal/bootstrap"
"drivervault/apiserver/internal/config" "drivervault/apiserver/internal/config"
"drivervault/apiserver/internal/pb" "drivervault/apiserver/internal/pb"
) )
@@ -44,6 +45,26 @@ func main() {
log.Println("WARNING: POCKETBASE_ADMIN_EMAIL/PASSWORD unset — management endpoints return 503 until configured") log.Println("WARNING: POCKETBASE_ADMIN_EMAIL/PASSWORD unset — management endpoints return 503 until configured")
} }
// Bring PocketBase up to the expected schema (create missing collections,
// reconcile existing ones) and ensure the super-admin. Idempotent, so it runs
// on every boot. Non-fatal: a fresh PocketBase that isn't reachable yet, or a
// bad service account, must not stop the panel from coming up so a superadmin
// can log in and fix the connection.
if cfg.Bootstrap && cfg.AdminConfigured() {
bootCtx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
if err := bootstrap.Run(bootCtx, client, bootstrap.Options{
UsersCollection: cfg.UsersCollection,
SuperAdminEmail: cfg.SuperAdminEmail,
SuperAdminPassword: cfg.SuperAdminPassword,
SuperAdminName: cfg.SuperAdminName,
}); err != nil {
log.Printf("WARNING: bootstrap failed: %v", err)
} else {
log.Println("bootstrap: PocketBase schema ready")
}
cancel()
}
srv := api.New(cfg, client) srv := api.New(cfg, client)
if err := srv.StartPlugins(); err != nil { if err := srv.StartPlugins(); err != nil {
+425
View File
@@ -0,0 +1,425 @@
// Package bootstrap brings a fresh PocketBase up to the schema the API Server
// expects, on server startup. It is the Go port of scripts/setup-pocketbase.mjs
// and is idempotent: existing collections are reconciled (missing fields added,
// relation/select options fixed) rather than recreated, and an already-present
// super-admin user is left untouched.
//
// It creates the app-level collections and, optionally, the DriverVault
// super-admin account. It does NOT create the PocketBase *superuser* — that is a
// chicken-and-egg the REST API can't solve (creating a _superusers record needs
// an existing superuser token), so the PocketBase container upserts it from env
// on boot instead. Bootstrap authenticates with that same superuser.
package bootstrap
import (
"context"
"encoding/json"
"fmt"
"log"
"net/http"
"net/url"
"drivervault/apiserver/internal/pb"
)
// Options configures a bootstrap run. SuperAdmin* are optional: when either the
// email or password is empty, the super-admin step is skipped.
type Options struct {
UsersCollection string
SuperAdminEmail string
SuperAdminPassword string
SuperAdminName string
}
// fieldDef is a schema field normalized to a single shape; it is rendered into
// the right PocketBase wire format ("fields" for v0.23+, legacy "schema") per
// the detected server version.
type fieldDef struct {
name string
typ string
required bool
relTo string // relation target collection name
cascadeDelete bool // relations only; default true
values []string
onCreate bool // autodate only
onUpdate bool // autodate only
maxSize int // file/json only, bytes
mimeTypes []string
}
// Field builders, mirroring the F.* helpers in setup-pocketbase.mjs.
func fText(name string, required bool) fieldDef {
return fieldDef{name: name, typ: "text", required: required}
}
func fNumber(name string) fieldDef { return fieldDef{name: name, typ: "number"} }
func fBool(name string) fieldDef { return fieldDef{name: name, typ: "bool"} }
func fDate(name string, required bool) fieldDef {
return fieldDef{name: name, typ: "date", required: required}
}
func fRelation(name, relTo string, required, cascade bool) fieldDef {
return fieldDef{name: name, typ: "relation", required: required, relTo: relTo, cascadeDelete: cascade}
}
func fSelect(name string, values []string, required bool) fieldDef {
return fieldDef{name: name, typ: "select", required: required, values: values}
}
func fAutodate(name string, onCreate, onUpdate bool) fieldDef {
return fieldDef{name: name, typ: "autodate", onCreate: onCreate, onUpdate: onUpdate}
}
func fFile(name string, maxSize int, mimeTypes []string) fieldDef {
return fieldDef{name: name, typ: "file", maxSize: maxSize, mimeTypes: mimeTypes}
}
func fJSON(name string, maxSize int) fieldDef {
return fieldDef{name: name, typ: "json", maxSize: maxSize}
}
// attachment is the single optional file a record can carry (scan/receipt/photo).
// The 10MB cap matches maxAttachmentUpload in the API server.
func attachment() fieldDef {
return fFile("file", 10485760, []string{
"application/pdf", "image/jpeg", "image/png", "image/webp", "image/heic",
})
}
// renderField turns a fieldDef into the map PocketBase expects, in either the
// modern ("fields") or legacy ("schema") layout. idByName resolves a relation's
// target collection name to its id.
func renderField(d fieldDef, format string, idByName map[string]string) map[string]any {
if format == "schema" {
options := map[string]any{}
switch d.typ {
case "relation":
options["collectionId"] = idByName[d.relTo]
options["cascadeDelete"] = d.cascadeDelete
options["maxSelect"] = 1
options["minSelect"] = 0
case "select":
options["values"] = d.values
options["maxSelect"] = 1
case "file":
options["maxSelect"] = 1
options["maxSize"] = d.maxSize
options["mimeTypes"] = mimeOrEmpty(d.mimeTypes)
case "json":
options["maxSize"] = d.maxSize
}
return map[string]any{"name": d.name, "type": d.typ, "required": d.required, "options": options}
}
field := map[string]any{"name": d.name, "type": d.typ, "required": d.required}
switch d.typ {
case "relation":
field["collectionId"] = idByName[d.relTo]
field["cascadeDelete"] = d.cascadeDelete
field["maxSelect"] = 1
field["minSelect"] = 0
case "select":
field["values"] = d.values
field["maxSelect"] = 1
case "autodate":
field["onCreate"] = d.onCreate
field["onUpdate"] = d.onUpdate
case "file":
field["maxSelect"] = 1
field["maxSize"] = d.maxSize
field["mimeTypes"] = mimeOrEmpty(d.mimeTypes)
case "json":
field["maxSize"] = d.maxSize
}
return field
}
func mimeOrEmpty(m []string) []string {
if m == nil {
return []string{}
}
return m
}
// Run authenticates as the superuser, creates any missing collections, reconciles
// existing ones, and (when configured) ensures the DriverVault super-admin exists.
// It is safe to call on every startup.
func Run(ctx context.Context, client *pb.Client, opts Options) error {
if err := client.Authenticate(ctx); err != nil {
return fmt.Errorf("authenticate: %w", err)
}
existing, err := listCollections(ctx, client)
if err != nil {
return fmt.Errorf("list collections: %w", err)
}
format := detectFormat(existing)
log.Printf("bootstrap: schema format %q", format)
present := make(map[string]bool, len(existing))
idByName := make(map[string]string, len(existing))
for _, c := range existing {
present[c.Name] = true
idByName[c.Name] = c.ID
}
// Create in dependency order: organizations before users references it; cars
// before its relations. "users" is PocketBase's built-in auth collection and
// is never created here — only reconciled below.
for _, name := range createOrder {
if present[name] {
continue
}
if err := createCollection(ctx, client, name, format, idByName); err != nil {
return fmt.Errorf("create %s: %w", name, err)
}
log.Printf("bootstrap: ✓ %s created", name)
// Refresh so later relations resolve newly-created collection ids.
refreshed, err := listCollections(ctx, client)
if err != nil {
return fmt.Errorf("re-list collections: %w", err)
}
for _, c := range refreshed {
present[c.Name] = true
idByName[c.Name] = c.ID
}
}
// Reconcile fields on existing collections (add missing + fix relation
// cascade and select value lists).
for _, name := range reconcileOrder {
if err := reconcileFields(ctx, client, name, format, idByName); err != nil {
return fmt.Errorf("reconcile %s: %w", name, err)
}
}
if err := ensureSuperAdmin(ctx, client, opts); err != nil {
return fmt.Errorf("super-admin: %w", err)
}
return nil
}
// --- PocketBase collection calls -------------------------------------------
type collectionMeta struct {
ID string `json:"id"`
Name string `json:"name"`
Fields []map[string]any `json:"fields"`
Schema []map[string]any `json:"schema"`
}
func listCollections(ctx context.Context, client *pb.Client) ([]collectionMeta, error) {
raw, status, err := client.Raw(ctx, http.MethodGet, "/api/collections?perPage=200", nil)
if err != nil {
return nil, err
}
if status < 200 || status >= 300 {
return nil, fmt.Errorf("status %d: %s", status, raw)
}
var env struct {
Items []collectionMeta `json:"items"`
}
if err := json.Unmarshal(raw, &env); err != nil {
return nil, err
}
return env.Items, nil
}
// detectFormat reports whether this PocketBase serializes fields under "fields"
// (v0.23+) or the legacy "schema" key, defaulting to the modern format.
func detectFormat(cols []collectionMeta) string {
for _, c := range cols {
if len(c.Fields) > 0 {
return "fields"
}
if len(c.Schema) > 0 {
return "schema"
}
}
return "fields"
}
func createCollection(ctx context.Context, client *pb.Client, name, format string, idByName map[string]string) error {
rendered := make([]map[string]any, 0, len(collectionsSchema[name]))
for _, d := range collectionsSchema[name] {
rendered = append(rendered, renderField(d, format, idByName))
}
body := map[string]any{
"name": name,
"type": "base",
format: rendered, // "fields" or "schema"
// Rules left null => superuser-only access (the API Server is the only client).
"listRule": nil,
"viewRule": nil,
"createRule": nil,
"updateRule": nil,
"deleteRule": nil,
}
if idx := indexes[name]; len(idx) > 0 {
body["indexes"] = idx
}
raw, status, err := client.Raw(ctx, http.MethodPost, "/api/collections", body)
if err != nil {
return err
}
if status < 200 || status >= 300 {
return fmt.Errorf("status %d: %s", status, raw)
}
var created struct {
ID string `json:"id"`
}
if err := json.Unmarshal(raw, &created); err == nil {
idByName[name] = created.ID
}
return nil
}
// reconcileFields brings an existing collection in line with the desired schema:
// it appends missing fields and updates relation cascadeDelete and select value
// lists on existing fields, preserving every existing field (including system
// fields) and their ids.
func reconcileFields(ctx context.Context, client *pb.Client, name, format string, idByName map[string]string) error {
raw, status, err := client.Raw(ctx, http.MethodGet, "/api/collections/"+name, nil)
if err != nil {
return err
}
if status < 200 || status >= 300 {
return fmt.Errorf("get: status %d: %s", status, raw)
}
var col collectionMeta
if err := json.Unmarshal(raw, &col); err != nil {
return err
}
current := col.Fields
if format == "schema" {
current = col.Schema
}
defs := collectionsSchema[name]
defByName := make(map[string]fieldDef, len(defs))
for _, d := range defs {
defByName[d.name] = d
}
var changes []string
merged := make([]map[string]any, 0, len(current)+len(defs))
haveName := make(map[string]bool, len(current))
for _, f := range current {
fname, _ := f["name"].(string)
haveName[fname] = true
if def, ok := defByName[fname]; ok {
switch def.typ {
case "relation":
if asBool(f["cascadeDelete"]) != def.cascadeDelete {
f["cascadeDelete"] = def.cascadeDelete
changes = append(changes, fname+".cascadeDelete")
}
case "select":
if !sameValues(f["values"], def.values) {
f["values"] = def.values
changes = append(changes, fname+".values")
}
}
}
merged = append(merged, f)
}
// Append missing fields.
for _, d := range defs {
if !haveName[d.name] {
merged = append(merged, renderField(d, format, idByName))
changes = append(changes, "+"+d.name)
}
}
if len(changes) == 0 {
log.Printf("bootstrap: • %s up to date", name)
return nil
}
patch := map[string]any{format: merged}
raw, status, err = client.Raw(ctx, http.MethodPatch, "/api/collections/"+col.ID, patch)
if err != nil {
return err
}
if status < 200 || status >= 300 {
return fmt.Errorf("patch: status %d: %s", status, raw)
}
log.Printf("bootstrap: ✓ %s — %v", name, changes)
return nil
}
// ensureSuperAdmin creates the DriverVault super-admin user when it does not yet
// exist. An already-present account (matched by email) is left untouched.
func ensureSuperAdmin(ctx context.Context, client *pb.Client, opts Options) error {
if opts.SuperAdminEmail == "" || opts.SuperAdminPassword == "" {
return nil // not requested
}
coll := opts.UsersCollection
if coll == "" {
coll = "users"
}
filter := url.QueryEscape(fmt.Sprintf("email='%s'", opts.SuperAdminEmail))
raw, status, err := client.Raw(ctx, http.MethodGet,
"/api/collections/"+coll+"/records?perPage=1&filter="+filter, nil)
if err != nil {
return err
}
if status < 200 || status >= 300 {
return fmt.Errorf("lookup: status %d: %s", status, raw)
}
var found struct {
TotalItems int `json:"totalItems"`
}
if err := json.Unmarshal(raw, &found); err != nil {
return err
}
if found.TotalItems > 0 {
log.Printf("bootstrap: • super-admin %s already exists", opts.SuperAdminEmail)
return nil
}
name := opts.SuperAdminName
if name == "" {
name = "Administrator"
}
create := map[string]any{
"email": opts.SuperAdminEmail,
"password": opts.SuperAdminPassword,
"passwordConfirm": opts.SuperAdminPassword,
"name": name,
"role": "superadmin",
"verified": true,
"emailVisibility": false,
}
raw, status, err = client.Raw(ctx, http.MethodPost, "/api/collections/"+coll+"/records", create)
if err != nil {
return err
}
if status < 200 || status >= 300 {
return fmt.Errorf("create: status %d: %s", status, raw)
}
log.Printf("bootstrap: ✓ super-admin %s created", opts.SuperAdminEmail)
return nil
}
// --- helpers ---------------------------------------------------------------
func asBool(v any) bool {
b, _ := v.(bool)
return b
}
// sameValues reports whether a select field's current values equal the desired
// set (order-insensitive), matching the reconcile check in setup-pocketbase.mjs.
func sameValues(current any, want []string) bool {
arr, ok := current.([]any)
if !ok || len(arr) != len(want) {
return false
}
have := make(map[string]bool, len(arr))
for _, v := range arr {
if s, ok := v.(string); ok {
have[s] = true
}
}
for _, w := range want {
if !have[w] {
return false
}
}
return true
}
@@ -0,0 +1,96 @@
package bootstrap
import "testing"
// TestRenderFieldModern locks the v0.23+ ("fields") wire shape for each field
// type against what PocketBase expects.
func TestRenderFieldModern(t *testing.T) {
idByName := map[string]string{"cars": "col_cars"}
rel := renderField(fRelation("car", "cars", true, true), "fields", idByName)
if rel["collectionId"] != "col_cars" || rel["cascadeDelete"] != true ||
rel["maxSelect"] != 1 || rel["minSelect"] != 0 || rel["required"] != true {
t.Fatalf("relation rendered wrong: %#v", rel)
}
nonCascade := renderField(fRelation("owner", "users", false, false), "fields", map[string]string{"users": "u"})
if nonCascade["cascadeDelete"] != false {
t.Fatalf("non-cascading relation should keep cascadeDelete=false: %#v", nonCascade)
}
sel := renderField(fSelect("result", []string{"passed", "failed"}, false), "fields", idByName)
if sel["maxSelect"] != 1 {
t.Fatalf("select maxSelect: %#v", sel)
}
if vals, ok := sel["values"].([]string); !ok || len(vals) != 2 {
t.Fatalf("select values: %#v", sel["values"])
}
ad := renderField(fAutodate("created", true, false), "fields", idByName)
if ad["onCreate"] != true || ad["onUpdate"] != false {
t.Fatalf("autodate: %#v", ad)
}
file := renderField(attachment(), "fields", idByName)
if file["maxSize"] != 10485760 || file["maxSelect"] != 1 {
t.Fatalf("file: %#v", file)
}
if mt, ok := file["mimeTypes"].([]string); !ok || len(mt) != 5 {
t.Fatalf("file mimeTypes: %#v", file["mimeTypes"])
}
j := renderField(fJSON("params", 10000), "fields", idByName)
if j["maxSize"] != 10000 {
t.Fatalf("json: %#v", j)
}
}
// TestRenderFieldLegacy checks that the legacy ("schema") layout nests options.
func TestRenderFieldLegacy(t *testing.T) {
rel := renderField(fRelation("car", "cars", true, true), "schema", map[string]string{"cars": "col_cars"})
opts, ok := rel["options"].(map[string]any)
if !ok {
t.Fatalf("legacy relation should nest options: %#v", rel)
}
if opts["collectionId"] != "col_cars" || opts["cascadeDelete"] != true {
t.Fatalf("legacy relation options: %#v", opts)
}
}
// TestSameValues covers the select-reconcile comparison (order-insensitive).
func TestSameValues(t *testing.T) {
if !sameValues([]any{"a", "b"}, []string{"b", "a"}) {
t.Fatal("same set in different order should compare equal")
}
if sameValues([]any{"a"}, []string{"a", "b"}) {
t.Fatal("differing lengths should not be equal")
}
if sameValues([]any{"a", "c"}, []string{"a", "b"}) {
t.Fatal("differing members should not be equal")
}
if sameValues(nil, []string{"a"}) {
t.Fatal("nil current should not equal non-empty want")
}
}
// TestSchemaConsistency guards the ordered lists against the schema map.
func TestSchemaConsistency(t *testing.T) {
for _, name := range createOrder {
if _, ok := collectionsSchema[name]; !ok {
t.Errorf("createOrder references unknown collection %q", name)
}
if name == "users" {
t.Errorf("users must not be in createOrder (built-in auth collection)")
}
}
for _, name := range reconcileOrder {
if _, ok := collectionsSchema[name]; !ok {
t.Errorf("reconcileOrder references unknown collection %q", name)
}
}
for name := range indexes {
if _, ok := collectionsSchema[name]; !ok {
t.Errorf("indexes references unknown collection %q", name)
}
}
}
+218
View File
@@ -0,0 +1,218 @@
package bootstrap
// This file is the Go mirror of the DESIRED schema, create order, reconcile
// order and INDEXES in scripts/setup-pocketbase.mjs. Keep the two in sync: edit
// here and re-run the server (bootstrap applies on startup), or run the script.
//
// Access rules are left null on every collection on purpose — every client goes
// through the API Server, which authenticates as a superuser, so the database is
// never exposed directly (including attachments, proxied by the API).
// collectionsSchema is the desired field set per collection.
var collectionsSchema = map[string][]fieldDef{
"cars": {
fText("name", true),
fText("make", false),
fText("model", false),
fNumber("year"),
fText("registration", false),
fText("registration_country", false),
fText("vin", false),
fNumber("service_interval_days"),
fNumber("service_interval_km"),
// Roadworthiness inspection cycle. Only prefills a check's next-due date.
fNumber("technical_check_interval_days"),
fText("oil_spec", false),
fText("transmission_oil_spec", false),
fText("differential_oil_spec", false),
fText("brake_fluid_spec", false),
fText("coolant_spec", false),
fNumber("current_km"),
// Bi-fuel LPG conversions are their own choice: the car runs on either tank.
fSelect("fuel_type", []string{
"petrol", "petrol_lpg", "diesel", "diesel_lpg", "hybrid", "electric", "hydrogen",
}, false),
fText("build_date", false), // ISO YYYY-MM-DD (date-only)
fText("first_registration_date", false), // ISO YYYY-MM-DD
// Owner of this car. Non-cascading: deleting a user must not wipe their cars.
fRelation("owner", "users", false, false),
},
"service_records": {
fRelation("car", "cars", true, true),
fDate("date", true),
fNumber("km"),
fBool("changed_oil"),
fBool("changed_engine_air_filter"),
fBool("changed_cabin_air_filter"),
fText("notes", false),
attachment(), // the workshop receipt / stamped service-book page
},
// Mandatory roadworthiness inspections (przegląd techniczny / MOT / TÜV).
"technical_checks": {
fRelation("car", "cars", true, true),
fDate("date", true),
fSelect("result", []string{"passed", "failed"}, false),
fNumber("cost"),
fText("station", false),
fDate("valid_until", false),
fText("notes", false),
attachment(), // the certificate
},
"parts": {
fRelation("car", "cars", true, true),
fText("name", true),
fText("part_number", false),
fText("category", false),
fText("notes", false),
attachment(), // a photo of the box, or the part's spec sheet
},
// Fuel refills. Consumption is derived on read, not stored.
"fuel_entries": {
fRelation("car", "cars", true, true),
fDate("date", true),
fNumber("km"), // odometer at the pump
fNumber("liters"),
fNumber("cost"),
fBool("full_tank"),
fBool("missed_fill"),
fText("station", false),
fText("notes", false),
attachment(), // the pump receipt
},
// Workshop visits and repairs (unplanned/one-off garage work with a labour bill).
"maintenance_entries": {
fRelation("car", "cars", true, true),
fDate("date", true),
fNumber("km"),
fSelect("type", []string{"repair", "inspection", "bodywork", "tyres", "diagnostics", "recall", "warranty", "other"}, false),
fSelect("status", []string{"scheduled", "in_progress", "completed"}, false),
fText("workshop", false),
fText("location", false),
fText("description", false),
fText("parts_used", false),
fNumber("labor_cost"),
fNumber("parts_cost"),
fText("invoice_number", false),
fDate("warranty_until", false),
fText("notes", false),
attachment(), // the workshop's invoice
},
// Insurance, pollution certificates, registration papers … expiry drives reminders.
"car_documents": {
fRelation("car", "cars", true, true),
fSelect("type", []string{"insurance", "pollution", "registration", "inspection", "roadTax", "warranty", "other"}, false),
fText("title", true),
fText("provider", false),
fText("reference", false),
fDate("issue_date", false),
fDate("expiry_date", false),
fNumber("cost"),
fText("notes", false),
attachment(), // the scan/PDF of the paperwork
},
// User-set reminders (derived document/service ones are computed on read).
"reminders": {
fRelation("car", "cars", true, true),
fText("title", true),
fSelect("type", []string{"maintenance", "document", "service", "inspection", "other"}, false),
fDate("due_date", false),
fNumber("due_km"),
fNumber("repeat_days"),
fNumber("repeat_km"),
fBool("done"),
fDate("done_at", false),
fText("notes", false),
},
// Per-car sharing grants. Cascades on both relations.
"car_shares": {
fRelation("car", "cars", true, true),
fRelation("user", "users", true, true),
fSelect("permission", []string{"read", "write"}, true),
fAutodate("created", true, false),
},
// Append-only audit trail for OCPP charger control. Actor/org stored as plain
// text ids (not relations) so the trail survives user or org deletion.
"control_audit": {
fText("user_id", false),
fText("org_id", false),
fText("serial", false),
fText("action", true),
fText("result", false),
fJSON("params", 10000),
fAutodate("created", true, false),
},
// Tenants that users belong to.
"organizations": {
fText("name", true),
fAutodate("created", true, false),
// Per-organization plugin/integration config (middle layer of the cascade).
fJSON("pluginSettings", 100000),
},
// Custom fields layered onto the built-in "users" auth collection.
"users": {
fText("bio", false),
fSelect("theme", []string{"light", "dark", "system"}, false),
fText("locale", false),
fSelect("date_format", []string{"YMD", "DMY_NUM", "DMY", "MDY"}, false),
fSelect("currency", []string{
"EUR", "GBP", "CHF", "PLN", "CZK", "HUF", "RON", "BGN", "DKK", "SEK", "NOK",
"ISK", "ALL", "AMD", "AZN", "BAM", "BYN", "GEL", "MDL", "MKD", "RSD", "RUB",
"TRY", "UAH", "USD", "CAD", "AUD", "JPY",
}, false),
fSelect("font_size", []string{"small", "medium", "large"}, false),
fDate("deletion_requested_at", false),
// Access role. Empty value is treated as "user" by the API.
fSelect("role", []string{"user", "admin", "superadmin"}, false),
// Organization membership. Non-cascading: deleting an org keeps its people.
fRelation("organization", "organizations", false, false),
// Per-user plugin/integration config (bottom layer of the cascade).
fJSON("pluginSettings", 100000),
},
}
// createOrder is the dependency order for creating missing collections.
// "users" is PocketBase's built-in auth collection and is never created here.
var createOrder = []string{
"organizations",
"cars",
"service_records",
"technical_checks",
"parts",
"car_shares",
"fuel_entries",
"maintenance_entries",
"car_documents",
"reminders",
"control_audit",
}
// reconcileOrder additionally includes "users" so its custom fields (role,
// organization, preferences) are added to the built-in collection.
var reconcileOrder = []string{
"organizations",
"users",
"cars",
"service_records",
"technical_checks",
"parts",
"car_shares",
"fuel_entries",
"maintenance_entries",
"car_documents",
"reminders",
"control_audit",
}
// indexes are extra SQL indexes applied at collection-create time.
var indexes = map[string][]string{
"organizations": {"CREATE UNIQUE INDEX `idx_organizations_name` ON `organizations` (`name`)"},
"fuel_entries": {"CREATE INDEX `idx_fuel_entries_car_km` ON `fuel_entries` (`car`, `km`)"},
"maintenance_entries": {"CREATE INDEX `idx_maintenance_entries_car_date` ON `maintenance_entries` (`car`, `date`)"},
"car_documents": {"CREATE INDEX `idx_car_documents_car_expiry` ON `car_documents` (`car`, `expiry_date`)"},
"reminders": {"CREATE INDEX `idx_reminders_car_due` ON `reminders` (`car`, `due_date`)"},
"technical_checks": {"CREATE INDEX `idx_technical_checks_car_date` ON `technical_checks` (`car`, `date`)"},
"control_audit": {
"CREATE INDEX `idx_control_audit_serial_created` ON `control_audit` (`serial`, `created`)",
"CREATE INDEX `idx_control_audit_user_created` ON `control_audit` (`user_id`, `created`)",
},
}
+16
View File
@@ -36,6 +36,18 @@ type Config struct {
// the charger at, instead of deriving it from request headers. // the charger at, instead of deriving it from request headers.
OCPPRequireTLS bool OCPPRequireTLS bool
OCPPPublicURL string OCPPPublicURL string
// Bootstrap controls the on-startup PocketBase schema setup: when true (the
// default) the server creates any missing collections and reconciles existing
// ones against the desired schema, then ensures the super-admin below exists.
// It requires a configured service account; without one it is a no-op.
Bootstrap bool
// Super-admin account created during bootstrap when both fields are set and no
// user with that email exists yet. Existing accounts are left untouched.
SuperAdminEmail string
SuperAdminPassword string
SuperAdminName string
} }
// EnvFile is the .env path (relative to the working directory) that Load reads // EnvFile is the .env path (relative to the working directory) that Load reads
@@ -63,6 +75,10 @@ func Load() Config {
PocketBaseAdminPassword: firstEnv("POCKETBASE_ADMIN_PASSWORD", "PB_ADMIN_PASSWORD"), PocketBaseAdminPassword: firstEnv("POCKETBASE_ADMIN_PASSWORD", "PB_ADMIN_PASSWORD"),
OCPPRequireTLS: boolEnv("OCPP_REQUIRE_TLS", true), OCPPRequireTLS: boolEnv("OCPP_REQUIRE_TLS", true),
OCPPPublicURL: strings.TrimRight(getenv("OCPP_PUBLIC_URL", ""), "/"), OCPPPublicURL: strings.TrimRight(getenv("OCPP_PUBLIC_URL", ""), "/"),
Bootstrap: boolEnv("PB_BOOTSTRAP", true),
SuperAdminEmail: firstEnv("DRIVERVAULT_SUPERADMIN_EMAIL", "SUPERADMIN_EMAIL"),
SuperAdminPassword: firstEnv("DRIVERVAULT_SUPERADMIN_PASSWORD", "SUPERADMIN_PASSWORD"),
SuperAdminName: getenv("DRIVERVAULT_SUPERADMIN_NAME", "Administrator"),
} }
} }
+15 -2
View File
@@ -5,9 +5,22 @@
PB_ADMIN_EMAIL=admin@example.com PB_ADMIN_EMAIL=admin@example.com
PB_ADMIN_PASSWORD=change-me-long-password PB_ADMIN_PASSWORD=change-me-long-password
# --- DriverVault super-admin (app login) -------------------------------------
# The first application user, created by the API Server on boot with role
# "superadmin" if no user with this email exists yet. Leave blank to skip.
DRIVERVAULT_SUPERADMIN_EMAIL=owner@example.com
DRIVERVAULT_SUPERADMIN_PASSWORD=change-me-long-password
DRIVERVAULT_SUPERADMIN_NAME=Administrator
# Set to false to skip schema creation/reconcile once the database is set up.
PB_BOOTSTRAP=true
# Allowed CORS origin(s) — match your web origin / WEB_PORT.
CORS_ALLOW_ORIGINS=http://localhost:8090
# --- Host port mappings (optional; defaults shown) -------------------------- # --- Host port mappings (optional; defaults shown) --------------------------
WEB_PORT=80 WEB_PORT=8090
PB_PORT=8090 PB_PORT=8070
# API Server + its embedded web panel (served at the API root, http://host:8080/). # API Server + its embedded web panel (served at the API root, http://host:8080/).
API_PORT=8080 API_PORT=8080
+35
View File
@@ -0,0 +1,35 @@
# DriverVault all-in-one — production config.
# Copy to .env and fill in, then:
# docker compose -f docker-compose.prod.yml pull
# docker compose -f docker-compose.prod.yml up -d
# --- Registry image ----------------------------------------------------------
AIO_IMAGE=10.2.1.10:5500/admin/drivervault-aio:latest
# --- PocketBase superuser (required) -----------------------------------------
# Created/updated on first boot. The API Server uses these to manage the database.
PB_ADMIN_EMAIL=admin@example.com
PB_ADMIN_PASSWORD=change-me-long-password
# --- DriverVault super-admin (app login) -------------------------------------
# The first application user, created by the API Server on boot with role
# "superadmin" if no user with this email exists yet. Leave blank to skip.
DRIVERVAULT_SUPERADMIN_EMAIL=owner@example.com
DRIVERVAULT_SUPERADMIN_PASSWORD=change-me-long-password
DRIVERVAULT_SUPERADMIN_NAME=Administrator
# Set to false to skip schema creation/reconcile once the database is set up.
PB_BOOTSTRAP=true
# Allowed CORS origin(s) — match your public web URL / WEB_PORT.
CORS_ALLOW_ORIGINS=http://localhost:8090
# --- Host port mappings (optional; defaults shown) --------------------------
WEB_PORT=8090
PB_PORT=8070
API_PORT=8080
# --- Storage -----------------------------------------------------------------
# Default is a Docker-managed named volume ("pb_data"). Set PB_DATA to an
# absolute host path for a bind mount, e.g. PB_DATA=/srv/drivervault/pb_data
PB_DATA=pb_data
+13 -8
View File
@@ -9,13 +9,16 @@
# #
# Run it (all three services start together): # Run it (all three services start together):
# #
# docker run -d --name drivervault -p 80:80 -p 8090:8090 \ # docker run -d --name drivervault -p 80:80 -p 8070:8070 \
# -e PB_ADMIN_EMAIL=admin@example.com \ # -e PB_ADMIN_EMAIL=admin@example.com \
# -e PB_ADMIN_PASSWORD=change-me \ # -e PB_ADMIN_PASSWORD=change-me \
# -e DRIVERVAULT_SUPERADMIN_EMAIL=owner@example.com \
# -e DRIVERVAULT_SUPERADMIN_PASSWORD=change-me \
# -v drivervault_pb:/pb/pb_data \ # -v drivervault_pb:/pb/pb_data \
# drivervault-aio # drivervault-aio
# #
# Then: web app on http://host/ and PocketBase admin on http://host:8090/_/ # Then: web app on http://host/ and PocketBase admin on http://host:8070/_/
# On first boot the API Server creates the collections and the super-admin.
# --- Stage 1: build the Go API Server --------------------------------------- # --- Stage 1: build the Go API Server ---------------------------------------
FROM golang:1.26-alpine AS api-build FROM golang:1.26-alpine AS api-build
@@ -115,7 +118,7 @@ logfile_maxbytes=0
; PocketBase: upsert the superuser (idempotent) then serve. ; PocketBase: upsert the superuser (idempotent) then serve.
[program:pocketbase] [program:pocketbase]
directory=/pb directory=/pb
command=/bin/sh -c '/pb/pocketbase superuser upsert "$PB_ADMIN_EMAIL" "$PB_ADMIN_PASSWORD" 2>/dev/null || true; exec /pb/pocketbase serve --http=0.0.0.0:8090' command=/bin/sh -c '/pb/pocketbase superuser upsert "$PB_ADMIN_EMAIL" "$PB_ADMIN_PASSWORD" 2>/dev/null || true; exec /pb/pocketbase serve --http=0.0.0.0:8070'
priority=10 priority=10
autostart=true autostart=true
autorestart=true autorestart=true
@@ -126,7 +129,7 @@ stderr_logfile_maxbytes=0
; API Server: wait for PocketBase to be healthy, then start. ; API Server: wait for PocketBase to be healthy, then start.
[program:api-server] [program:api-server]
command=/bin/sh -c 'until wget -qO- http://127.0.0.1:8090/api/health >/dev/null 2>&1; do echo "waiting for pocketbase..."; sleep 1; done; exec /usr/local/bin/api-server' command=/bin/sh -c 'until wget -qO- http://127.0.0.1:8070/api/health >/dev/null 2>&1; do echo "waiting for pocketbase..."; sleep 1; done; exec /usr/local/bin/api-server'
priority=20 priority=20
autostart=true autostart=true
autorestart=true autorestart=true
@@ -148,15 +151,17 @@ SUPERVISOR
# API Server config: everything is local to this container. # API Server config: everything is local to this container.
ENV PORT=8080 \ ENV PORT=8080 \
PB_URL=http://127.0.0.1:8090 \ PB_URL=http://127.0.0.1:8070 \
CORS_ALLOW_ORIGINS=http://localhost \ CORS_ALLOW_ORIGINS=http://localhost:8090 \
AUTH_USERS_COLLECTION=users AUTH_USERS_COLLECTION=users
# Required at runtime (no safe defaults): PB_ADMIN_EMAIL, PB_ADMIN_PASSWORD. # Required at runtime (no safe defaults): PB_ADMIN_EMAIL, PB_ADMIN_PASSWORD.
# Optional: DRIVERVAULT_SUPERADMIN_EMAIL / DRIVERVAULT_SUPERADMIN_PASSWORD create
# the first app super-admin on boot; PB_BOOTSTRAP=false skips schema setup.
# Pass them with `docker run -e ...`. # Pass them with `docker run -e ...`.
VOLUME /pb/pb_data VOLUME /pb/pb_data
# 80 = Web App, 8090 = PocketBase admin, 8080 = API Server + embedded API panel. # 80 = Web App, 8070 = PocketBase admin, 8080 = API Server + embedded API panel.
EXPOSE 80 8090 8080 EXPOSE 80 8070 8080
CMD ["supervisord", "-c", "/etc/supervisord.conf"] CMD ["supervisord", "-c", "/etc/supervisord.conf"]
+41
View File
@@ -0,0 +1,41 @@
name: drivervault-aio
# Production all-in-one — pulls the prebuilt image from the registry instead of
# building. One container runs PocketBase + API Server + Web App (nginx).
# Everything an operator needs to set lives in .env.
#
# 1. cp .env.prod.example .env (then edit it)
# 2. docker compose -f docker-compose.prod.yml pull
# 3. docker compose -f docker-compose.prod.yml up -d
#
# On first boot PocketBase upserts the superuser from PB_ADMIN_*, and the API
# Server creates any missing collections and the DriverVault super-admin from
# DRIVERVAULT_SUPERADMIN_*. Both steps are idempotent.
services:
drivervault:
image: "${AIO_IMAGE:-10.2.1.10:5500/admin/drivervault-aio:latest}"
container_name: drivervault-aio
restart: unless-stopped
environment:
# Superuser (also used by the API Server to authenticate to PocketBase).
PB_ADMIN_EMAIL: "${PB_ADMIN_EMAIL:?set PB_ADMIN_EMAIL in .env}"
PB_ADMIN_PASSWORD: "${PB_ADMIN_PASSWORD:?set PB_ADMIN_PASSWORD in .env}"
# Match CORS to the web origin (only used if a browser calls the API directly).
CORS_ALLOW_ORIGINS: "${CORS_ALLOW_ORIGINS:-http://localhost:8090}"
# Schema + super-admin bootstrap on boot (idempotent). Set PB_BOOTSTRAP=false
# to skip once the database is established.
PB_BOOTSTRAP: "${PB_BOOTSTRAP:-true}"
DRIVERVAULT_SUPERADMIN_EMAIL: "${DRIVERVAULT_SUPERADMIN_EMAIL:-}"
DRIVERVAULT_SUPERADMIN_PASSWORD: "${DRIVERVAULT_SUPERADMIN_PASSWORD:-}"
DRIVERVAULT_SUPERADMIN_NAME: "${DRIVERVAULT_SUPERADMIN_NAME:-Administrator}"
ports:
- "${WEB_PORT:-8090}:80" # Web App
- "${PB_PORT:-8070}:8070" # PocketBase admin UI / API
- "${API_PORT:-8080}:8080" # API Server + embedded API web panel (root /)
volumes:
# Named volume by default; set PB_DATA to a host path in .env for a bind mount.
- "${PB_DATA:-pb_data}:/pb/pb_data"
volumes:
pb_data:
+10 -2
View File
@@ -22,9 +22,17 @@ services:
# Superuser (also used by the API Server to authenticate to PocketBase). # Superuser (also used by the API Server to authenticate to PocketBase).
PB_ADMIN_EMAIL: "${PB_ADMIN_EMAIL:?set PB_ADMIN_EMAIL in .env}" PB_ADMIN_EMAIL: "${PB_ADMIN_EMAIL:?set PB_ADMIN_EMAIL in .env}"
PB_ADMIN_PASSWORD: "${PB_ADMIN_PASSWORD:?set PB_ADMIN_PASSWORD in .env}" PB_ADMIN_PASSWORD: "${PB_ADMIN_PASSWORD:?set PB_ADMIN_PASSWORD in .env}"
# Match CORS to the web origin (only used if a browser calls the API directly).
CORS_ALLOW_ORIGINS: "${CORS_ALLOW_ORIGINS:-http://localhost:8090}"
# Schema + super-admin bootstrap on boot (idempotent). Set PB_BOOTSTRAP=false
# to skip once the database is established.
PB_BOOTSTRAP: "${PB_BOOTSTRAP:-true}"
DRIVERVAULT_SUPERADMIN_EMAIL: "${DRIVERVAULT_SUPERADMIN_EMAIL:-}"
DRIVERVAULT_SUPERADMIN_PASSWORD: "${DRIVERVAULT_SUPERADMIN_PASSWORD:-}"
DRIVERVAULT_SUPERADMIN_NAME: "${DRIVERVAULT_SUPERADMIN_NAME:-Administrator}"
ports: ports:
- "${WEB_PORT:-80}:80" # Web App - "${WEB_PORT:-8090}:80" # Web App
- "${PB_PORT:-8090}:8090" # PocketBase admin UI / API - "${PB_PORT:-8070}:8070" # PocketBase admin UI / API
- "${API_PORT:-8080}:8080" # API Server + embedded API web panel (root /) - "${API_PORT:-8080}:8080" # API Server + embedded API web panel (root /)
volumes: volumes:
- pb_data:/pb/pb_data - pb_data:/pb/pb_data
+3 -3
View File
@@ -7,13 +7,13 @@ PB_ADMIN_PASSWORD=change-me-long-password
# --- API Server ------------------------------------------------------------- # --- API Server -------------------------------------------------------------
# Allowed CORS origin(s) for the web app (match WEB_PORT / your public URL). # Allowed CORS origin(s) for the web app (match WEB_PORT / your public URL).
# Native mobile apps are not subject to CORS. # Native mobile apps are not subject to CORS.
CORS_ALLOW_ORIGINS=http://localhost:8081 CORS_ALLOW_ORIGINS=http://localhost:8090
AUTH_USERS_COLLECTION=users AUTH_USERS_COLLECTION=users
# --- Host port mappings (optional; defaults shown) -------------------------- # --- Host port mappings (optional; defaults shown) --------------------------
PB_PORT=8090 PB_PORT=8070
API_PORT=8080 API_PORT=8080
WEB_PORT=8081 WEB_PORT=8090
# --- Web App build ----------------------------------------------------------- # --- Web App build -----------------------------------------------------------
# Leave empty so the browser uses same-origin /api (proxied by the BFF). # Leave empty so the browser uses same-origin /api (proxied by the BFF).
+49
View File
@@ -0,0 +1,49 @@
# DriverVault — production stack config.
# Copy to .env and fill in, then:
# docker compose -f docker-compose.prod.yml pull
# docker compose -f docker-compose.prod.yml up -d
# --- Registry images ---------------------------------------------------------
# Defaults point at the internal registry; override to pin a tag or use a mirror.
PB_IMAGE=10.2.1.10:5500/admin/drivervault-pocketbase:latest
API_IMAGE=10.2.1.10:5500/admin/drivervault-api-server:latest
WEB_IMAGE=10.2.1.10:5500/admin/drivervault-web-app:latest
# --- PocketBase superuser ----------------------------------------------------
# Created/updated on the PocketBase container's first boot. The API Server uses
# these same credentials to manage the database. REQUIRED.
PB_ADMIN_EMAIL=admin@example.com
PB_ADMIN_PASSWORD=change-me-long-password
# --- DriverVault super-admin (app login) -------------------------------------
# The first application user, created by the API Server on boot with role
# "superadmin" if no user with this email exists yet. Leave blank to skip and
# create the first user by hand. This is the account you log in to the web app
# with — distinct from the PocketBase superuser above.
DRIVERVAULT_SUPERADMIN_EMAIL=owner@example.com
DRIVERVAULT_SUPERADMIN_PASSWORD=change-me-long-password
DRIVERVAULT_SUPERADMIN_NAME=Administrator
# Set to false to skip schema creation/reconcile once the database is set up.
PB_BOOTSTRAP=true
# --- API Server --------------------------------------------------------------
# Allowed CORS origin(s) for the web app (match your public URL / WEB_PORT).
CORS_ALLOW_ORIGINS=http://localhost:8090
AUTH_USERS_COLLECTION=users
# --- Ports -------------------------------------------------------------------
# WEB_PORT is the public front door (bound on all interfaces).
WEB_PORT=8090
# PocketBase admin UI and the API panel are bound to localhost only by default.
# Set PB_BIND / API_BIND to 0.0.0.0 to expose them on the network.
PB_PORT=8070
PB_BIND=127.0.0.1
API_PORT=8080
API_BIND=127.0.0.1
# --- Storage -----------------------------------------------------------------
# Default is a Docker-managed named volume ("pb_data"). To store the database on
# a host path instead, set PB_DATA to an absolute path, e.g.:
# PB_DATA=/srv/drivervault/pb_data
PB_DATA=pb_data
+83
View File
@@ -0,0 +1,83 @@
name: drivervault
# Production DriverVault stack — pulls prebuilt images from the registry instead
# of building from source. Everything an operator needs to set lives in .env.
#
# 1. cp .env.prod.example .env (then edit it — all secrets/ports/volumes)
# 2. docker compose -f docker-compose.prod.yml pull
# 3. docker compose -f docker-compose.prod.yml up -d
#
# Traffic flow (browser): Web App BFF --/api--> API Server --> PocketBase.
#
# On first boot:
# • PocketBase upserts the superuser from PB_ADMIN_* (create-if-missing).
# • the API Server creates any missing collections, reconciles existing ones,
# and creates the DriverVault super-admin from DRIVERVAULT_SUPERADMIN_*.
# Both steps are idempotent, so restarts and upgrades are safe.
services:
pocketbase:
image: "${PB_IMAGE:-10.2.1.10:5500/admin/drivervault-pocketbase:latest}"
container_name: drivervault-pocketbase
restart: unless-stopped
environment:
# The superuser is created/updated on boot (the API Server authenticates
# with it). This is the only place the first superuser can be created — the
# REST API cannot bootstrap it.
PB_ADMIN_EMAIL: "${PB_ADMIN_EMAIL:?set PB_ADMIN_EMAIL in .env}"
PB_ADMIN_PASSWORD: "${PB_ADMIN_PASSWORD:?set PB_ADMIN_PASSWORD in .env}"
volumes:
# Named volume by default; set PB_DATA to a host path in .env for a bind mount.
- "${PB_DATA:-pb_data}:/pb/pb_data"
ports:
# Bound to localhost by default — the admin UI (/_/) is reachable only on
# the host. Set PB_BIND=0.0.0.0 in .env to expose it on the network.
- "${PB_BIND:-127.0.0.1}:${PB_PORT:-8070}:8070"
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:8070/api/health || exit 1"]
interval: 10s
timeout: 3s
retries: 12
start_period: 10s
api-server:
image: "${API_IMAGE:-10.2.1.10:5500/admin/drivervault-api-server:latest}"
container_name: drivervault-api
restart: unless-stopped
depends_on:
pocketbase:
condition: service_healthy
environment:
PORT: "8080"
# Reach PocketBase by its service name on the internal network.
PB_URL: "http://pocketbase:8070"
PB_ADMIN_EMAIL: "${PB_ADMIN_EMAIL}"
PB_ADMIN_PASSWORD: "${PB_ADMIN_PASSWORD}"
CORS_ALLOW_ORIGINS: "${CORS_ALLOW_ORIGINS:-http://localhost:8090}"
AUTH_USERS_COLLECTION: "${AUTH_USERS_COLLECTION:-users}"
# Schema + super-admin bootstrap (idempotent). Set PB_BOOTSTRAP=false to
# skip it once the database is established.
PB_BOOTSTRAP: "${PB_BOOTSTRAP:-true}"
DRIVERVAULT_SUPERADMIN_EMAIL: "${DRIVERVAULT_SUPERADMIN_EMAIL:-}"
DRIVERVAULT_SUPERADMIN_PASSWORD: "${DRIVERVAULT_SUPERADMIN_PASSWORD:-}"
DRIVERVAULT_SUPERADMIN_NAME: "${DRIVERVAULT_SUPERADMIN_NAME:-Administrator}"
ports:
# Localhost-only by default (the Web App reaches it over the internal
# network). Set API_BIND=0.0.0.0 to expose the API panel on the network.
- "${API_BIND:-127.0.0.1}:${API_PORT:-8080}:8080"
web-app:
image: "${WEB_IMAGE:-10.2.1.10:5500/admin/drivervault-web-app:latest}"
container_name: drivervault-web
restart: unless-stopped
depends_on:
- api-server
environment:
# The BFF reverse-proxies /api/* to the API Server over the internal network.
API_BASE: "http://api-server:8080"
ports:
# The public front door. Bound on all interfaces so browsers can reach it.
- "${WEB_PORT:-8090}:8090"
volumes:
pb_data:
+6 -6
View File
@@ -18,10 +18,10 @@ services:
volumes: volumes:
- pb_data:/pb/pb_data - pb_data:/pb/pb_data
ports: ports:
# Admin UI / API exposed on the host for management (http://host:8090/_/). # Admin UI / API exposed on the host for management (http://host:8070/_/).
- "${PB_PORT:-8090}:8090" - "${PB_PORT:-8070}:8070"
healthcheck: healthcheck:
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:8090/api/health || exit 1"] test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:8070/api/health || exit 1"]
interval: 10s interval: 10s
timeout: 3s timeout: 3s
retries: 12 retries: 12
@@ -39,12 +39,12 @@ services:
environment: environment:
PORT: "8080" PORT: "8080"
# Reach PocketBase by its service name on the internal network. # Reach PocketBase by its service name on the internal network.
PB_URL: "http://pocketbase:8090" PB_URL: "http://pocketbase:8070"
PB_ADMIN_EMAIL: "${PB_ADMIN_EMAIL}" PB_ADMIN_EMAIL: "${PB_ADMIN_EMAIL}"
PB_ADMIN_PASSWORD: "${PB_ADMIN_PASSWORD}" PB_ADMIN_PASSWORD: "${PB_ADMIN_PASSWORD}"
# Same-origin requests go through the Web App BFF, so CORS is only needed # Same-origin requests go through the Web App BFF, so CORS is only needed
# if the browser ever calls the API Server directly. Default to the web origin. # if the browser ever calls the API Server directly. Default to the web origin.
CORS_ALLOW_ORIGINS: "${CORS_ALLOW_ORIGINS:-http://localhost:8081}" CORS_ALLOW_ORIGINS: "${CORS_ALLOW_ORIGINS:-http://localhost:8090}"
AUTH_USERS_COLLECTION: "${AUTH_USERS_COLLECTION:-users}" AUTH_USERS_COLLECTION: "${AUTH_USERS_COLLECTION:-users}"
ports: ports:
# Optional direct access to the API Server (and its panel at /); the Web # Optional direct access to the API Server (and its panel at /); the Web
@@ -66,7 +66,7 @@ services:
# The BFF reverse-proxies /api/* to the API Server over the internal network. # The BFF reverse-proxies /api/* to the API Server over the internal network.
API_BASE: "http://api-server:8080" API_BASE: "http://api-server:8080"
ports: ports:
- "${WEB_PORT:-8081}:8090" - "${WEB_PORT:-8090}:8090"
volumes: volumes:
pb_data: pb_data:
+1 -1
View File
@@ -29,6 +29,6 @@ RUN chmod +x /entrypoint.sh
# pb_data holds the SQLite database and uploads — mount a volume here. # pb_data holds the SQLite database and uploads — mount a volume here.
VOLUME /pb/pb_data VOLUME /pb/pb_data
EXPOSE 8090 EXPOSE 8070
ENTRYPOINT ["/entrypoint.sh"] ENTRYPOINT ["/entrypoint.sh"]
+1 -1
View File
@@ -9,4 +9,4 @@ if [ -n "$PB_ADMIN_EMAIL" ] && [ -n "$PB_ADMIN_PASSWORD" ]; then
|| echo "warning: superuser upsert failed; create an admin via the UI at /_/" || echo "warning: superuser upsert failed; create an admin via the UI at /_/"
fi fi
exec /pb/pocketbase serve --http=0.0.0.0:8090 exec /pb/pocketbase serve --http=0.0.0.0:8070