name: drivervault # Full DriverVault stack, with SeaweedFS: PocketBase (database) + API Server + # Web App, built from source, plus an S3 object store. Self-contained — one # file, nothing to layer. # # cp .env.seaweedfs.example .env (then edit it) # docker compose -f docker-compose.seaweedfs.yml up -d --build # # Traffic flow (browser): Web App BFF --/api--> API Server --> PocketBase. # # This is docker-compose.yml plus storage: PocketBase keeps its record files — # document scans, service and refill receipts, workshop invoices, part photos — # in a SeaweedFS bucket instead of on the pb_data volume next to the database. # The database and PocketBase's own backups stay on pb_data. Clients cannot tell # the difference: an attachment has always been fetched through the API Server, # never from a storage URL. # # Before turning this on for a stack that already has uploads: PocketBase does # NOT copy existing files into the bucket. See README.md. services: seaweedfs: image: "${SEAWEED_IMAGE:-chrislusf/seaweedfs:4.45}" container_name: drivervault-seaweedfs restart: unless-stopped # One process, four roles: master, volume, filer and the S3 gateway. -dir is # the only state it keeps. command: server -dir=/data -s3 -master.volumeSizeLimitMB=1024 environment: # SeaweedFS falls back to these when started without an -s3.config file, # and configuring one identity is what takes the S3 gateway out of its # default allow-anyone mode. The same credentials PocketBase authenticates # with below — one pair to set, in .env. AWS_ACCESS_KEY_ID: "${PB_S3_ACCESS_KEY:?set PB_S3_ACCESS_KEY in .env}" AWS_SECRET_ACCESS_KEY: "${PB_S3_SECRET:?set PB_S3_SECRET in .env}" volumes: # From here on the attachments live here, not on pb_data. - seaweed_data:/data ports: # The stack reaches the gateway over the compose network; this is here so # `aws s3 ls --endpoint-url http://localhost:8333` works while developing. - "${SEAWEED_S3_PORT:-8333}:8333" healthcheck: test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:9333/cluster/status || exit 1"] interval: 10s timeout: 3s retries: 12 start_period: 20s seaweedfs-init: image: "${SEAWEED_IMAGE:-chrislusf/seaweedfs:4.45}" container_name: drivervault-seaweedfs-init # Runs once and exits. PocketBase never issues a CreateBucket of its own and # SeaweedFS will not conjure one on first upload, so something has to. # Creating a bucket that already exists is a no-op, so every later boot # passes straight through. restart: "no" depends_on: seaweedfs: condition: service_healthy entrypoint: ["/bin/sh", "-c"] # `|| true` so a restart is never blocked by the shell's exit status: this # step is best-effort, and a gateway that is genuinely unreachable is # reported by the API Server's own S3 check at boot, with the reason. command: - 'echo "s3.bucket.create -name ${PB_S3_BUCKET:-drivervault}" | weed shell -master=seaweedfs:9333 || true' pocketbase: build: context: ./pocketbase image: drivervault-pocketbase container_name: drivervault-pocketbase restart: unless-stopped depends_on: # PocketBase is the process that reads and writes the objects, so the # gateway has to be serving before it is asked to store anything. seaweedfs: condition: service_healthy environment: # Superuser is created/updated on boot so the API Server can authenticate. 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: - pb_data:/pb/pb_data ports: # Admin UI / API exposed on the host for management (http://host:8070/_/). - "${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: build: context: ../API Server image: drivervault-api container_name: drivervault-api restart: unless-stopped depends_on: pocketbase: condition: service_healthy # The bucket must exist before the bootstrap points PocketBase at it. seaweedfs-init: condition: service_completed_successfully environment: API_ADDR: ":8080" # Reach PocketBase by its service name on the internal network. Override # POCKETBASE_URL in .env to point the API Server at a database outside # this stack — that is also how you make a retarget done from the panel # permanent, since the panel's change lasts only for the container's life. POCKETBASE_URL: "${POCKETBASE_URL:-http://pocketbase:8070}" POCKETBASE_ADMIN_EMAIL: "${PB_ADMIN_EMAIL}" POCKETBASE_ADMIN_PASSWORD: "${PB_ADMIN_PASSWORD}" # Probed by the panel status page. This is a server-to-server call inside # the compose network, so the default is the service name — plain # localhost:8090 would resolve to this container itself. Override # WEBAPP_URL in .env to make a change from the panel's Web App screen # permanent; the panel alone only holds it for the container's life. WEBAPP_URL: "${WEBAPP_URL:-http://web-app:8090}" # 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. CORS_ALLOW_ORIGINS: "${CORS_ALLOW_ORIGINS:-http://localhost:8090}" AUTH_USERS_COLLECTION: "${AUTH_USERS_COLLECTION:-users}" # Schema + super-admin bootstrap (idempotent). Without the SUPERADMIN vars # the collections are still created but no app user is, leaving a stack # you cannot log into. # # Leave the bootstrap ON: a release can add collections or fields the # server needs, and a stack that skips it never gets them. The API Server # self-heals exactly one thing — app_settings, the collection holding the # plugin settings, which it creates on demand because it cannot serve the # plugin panel without it. Every other schema change still depends on this # flag. Turn it off only for a database you know matches the release. PB_BOOTSTRAP: "${PB_BOOTSTRAP:-true}" DRIVERVAULT_SUPERADMIN_EMAIL: "${DRIVERVAULT_SUPERADMIN_EMAIL:-}" DRIVERVAULT_SUPERADMIN_PASSWORD: "${DRIVERVAULT_SUPERADMIN_PASSWORD:-}" DRIVERVAULT_SUPERADMIN_NAME: "${DRIVERVAULT_SUPERADMIN_NAME:-Administrator}" # OCPP charger control (Anker Solix). Chargers are rejected unless they # connect over TLS; set OCPP_REQUIRE_TLS=false in .env only when TLS is # terminated in front of this stack or for local dev on a trusted network. OCPP_REQUIRE_TLS: "${OCPP_REQUIRE_TLS:-true}" OCPP_PUBLIC_URL: "${OCPP_PUBLIC_URL:-}" # Diagnostic: set ANKER_MQTT_FRAME_LOG=1 in .env to log every frame the # charger publishes over Anker's broker, decoded ones and unreadable ones # alike, with their bytes. It is how a frame nobody has named gets named — # do something in the Anker app while a control read holds the connection # open, and read the frames back out of the log. Off by default: with it on # a charger under a live trigger writes a line every few seconds. ANKER_MQTT_FRAME_LOG: "${ANKER_MQTT_FRAME_LOG:-}" # --- File storage -------------------------------------------------- # Read by the API Server's bootstrap, which writes them into PocketBase's # settings on every boot, idempotently. Only record files move — scans, # receipts, invoices, part photos. The database and PocketBase's own # backups stay on pb_data. PB_S3_ENABLED: "true" PB_S3_BUCKET: "${PB_S3_BUCKET:-drivervault}" # The service name: a server-to-server call inside the compose network. PB_S3_ENDPOINT: "http://seaweedfs:8333" # SeaweedFS ignores the region; PocketBase insists on having one. PB_S3_REGION: "${PB_S3_REGION:-us-east-1}" PB_S3_ACCESS_KEY: "${PB_S3_ACCESS_KEY}" PB_S3_SECRET: "${PB_S3_SECRET}" # Path style, because a self-hosted gateway has no per-bucket DNS. PB_S3_FORCE_PATH_STYLE: "true" ports: # Optional direct access to the API Server (and its panel at /); the Web # App reaches it over the internal network, not this host port. Chargers # dialling /ocpp/{serial} also arrive here. - "${API_PORT:-8080}:8080" # No volume: the API Server keeps no state on disk — every setting it owns, # plugin settings included, lives in PocketBase under pb_data. healthcheck: # Declared here rather than relying only on the image's HEALTHCHECK, so the # depends_on gate below still works against an older pulled image. test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:8080/healthz || exit 1"] interval: 10s timeout: 3s retries: 12 start_period: 20s web-app: build: context: ../Web App args: # Empty -> bundle uses same-origin "/api", which the BFF proxies below. VITE_API_BASE: "${VITE_API_BASE:-}" image: drivervault-web container_name: drivervault-web restart: unless-stopped depends_on: # The image now ships a HEALTHCHECK, so wait for the API Server to be # serving rather than merely started. api-server: condition: service_healthy environment: # The BFF reverse-proxies /api/* — and /ocpp/*, the address chargers are # told to dial — to the API Server over the internal network. API_BASE: "http://api-server:8080" # Believe an inbound X-Forwarded-Proto. The API Server reads it to decide a # charger arrived over TLS, so leave this off unless a TLS-terminating # proxy in front of the stack is the only way in: otherwise a charger could # claim wss over a plaintext connection. Set it to true when TLS ends at # that proxy and OCPP_PUBLIC_URL names a wss:// base through it. TRUST_FORWARDED_PROTO: "${TRUST_FORWARDED_PROTO:-false}" ports: - "${WEB_PORT:-8090}:8090" healthcheck: test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:8090/healthz || exit 1"] interval: 10s timeout: 3s retries: 12 start_period: 10s volumes: pb_data: seaweed_data: