PocketBase reaches the gateway over the compose network (or inside the AIO container), so publishing 8333 on the host only made a second SeaweedFS stack fail with "port is already allocated". Drop the binding and SEAWEED_S3_BIND/SEAWEED_S3_PORT. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
377 lines
17 KiB
YAML
377 lines
17 KiB
YAML
name: drivervault
|
|
|
|
# Full DriverVault stack, with SeaweedFS split into its four roles: PocketBase
|
|
# (database) + API Server + Web App, built from source, plus master, volume,
|
|
# filer, S3 gateway and the SeaweedFS admin UI as separate containers.
|
|
# Self-contained — one file, nothing to layer.
|
|
#
|
|
# cp .env.seaweedfs.split.example .env (then edit it)
|
|
# docker compose -f docker-compose.seaweedfs.split.yml up -d --build
|
|
#
|
|
# Traffic flow (browser): Web App BFF --/api--> API Server --> PocketBase.
|
|
#
|
|
# This is docker-compose.seaweedfs.yml with the storage layer taken apart.
|
|
# `weed server -s3` runs master, volume, filer and gateway as goroutines in one
|
|
# process; here each is its own container. What that buys:
|
|
#
|
|
# • the admin UI (weed admin) — a cluster view, and Object Store → Users,
|
|
# where S3 identities are created and revoked without touching a file;
|
|
# • per-role restart, upgrade and Prometheus metrics;
|
|
# • room to add a second volume server later, on this host or another.
|
|
#
|
|
# What it costs: five containers instead of one, and five healthchecks to keep
|
|
# the boot order honest. If none of the above is wanted, use
|
|
# docker-compose.seaweedfs.yml — the S3 behaviour is identical.
|
|
#
|
|
# The on-disk layout is deliberately the same as the single-process file's:
|
|
# master, volume and filer share one /data mount, exactly as `weed server -dir`
|
|
# lays it out (master raft state, volume .dat/.idx, the filer's filerldb2/ — no
|
|
# filename overlap). So the two files are interchangeable on the same volume,
|
|
# with no migration either way. A *second* volume server would need its own.
|
|
#
|
|
# 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: master -----------------------------------------------------
|
|
# Keeps the volume/topology metadata and hands out file ids. -ip is the name
|
|
# the other roles are told to reach it by, so it must be the service name and
|
|
# not the container IP the process would otherwise detect.
|
|
seaweedfs-master:
|
|
image: "${SEAWEED_IMAGE:-chrislusf/seaweedfs:4.45}"
|
|
container_name: drivervault-seaweedfs-master
|
|
restart: unless-stopped
|
|
command: >
|
|
master -ip=seaweedfs-master -ip.bind=0.0.0.0 -mdir=/data
|
|
-volumeSizeLimitMB=1024 -metricsPort=9324
|
|
volumes:
|
|
- seaweed_data:/data
|
|
ports:
|
|
# Master UI / API. Useful while developing; the admin UI below covers the
|
|
# same ground with a nicer face.
|
|
- "${SEAWEED_MASTER_PORT:-9333}:9333"
|
|
healthcheck:
|
|
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:9333/cluster/status || exit 1"]
|
|
interval: 10s
|
|
timeout: 3s
|
|
retries: 12
|
|
start_period: 15s
|
|
|
|
# --- SeaweedFS: volume server ---------------------------------------------
|
|
# Where the bytes actually land. -max=0 lets it size itself from free disk
|
|
# rather than the default cap of 8 volumes.
|
|
seaweedfs-volume:
|
|
image: "${SEAWEED_IMAGE:-chrislusf/seaweedfs:4.45}"
|
|
container_name: drivervault-seaweedfs-volume
|
|
restart: unless-stopped
|
|
command: >
|
|
volume -master=seaweedfs-master:9333 -ip=seaweedfs-volume -ip.bind=0.0.0.0
|
|
-port=8080 -dir=/data -max=0 -metricsPort=9325
|
|
depends_on:
|
|
seaweedfs-master:
|
|
condition: service_healthy
|
|
volumes:
|
|
- seaweed_data:/data
|
|
ports:
|
|
# This port serves file content by file id with NO authentication — the S3
|
|
# credentials do not apply to it. Publish it only where you would be
|
|
# willing to publish the bucket itself. Mapped to 8081 on the host because
|
|
# 8080 there is the API Server.
|
|
- "${SEAWEED_VOLUME_PORT:-8081}:8080"
|
|
healthcheck:
|
|
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:8080/healthz || exit 1"]
|
|
interval: 10s
|
|
timeout: 3s
|
|
retries: 12
|
|
start_period: 15s
|
|
|
|
# --- SeaweedFS: filer ------------------------------------------------------
|
|
# Gives the flat volume store a directory tree — buckets, object keys — and
|
|
# holds the S3 identities the admin UI writes. -defaultStoreDir is where its
|
|
# embedded leveldb goes; without it that would be the container's working
|
|
# directory, and the identities would not survive a recreate.
|
|
seaweedfs-filer:
|
|
image: "${SEAWEED_IMAGE:-chrislusf/seaweedfs:4.45}"
|
|
container_name: drivervault-seaweedfs-filer
|
|
restart: unless-stopped
|
|
command: >
|
|
filer -master=seaweedfs-master:9333 -ip=seaweedfs-filer -ip.bind=0.0.0.0
|
|
-port=8888 -defaultStoreDir=/data -metricsPort=9326
|
|
depends_on:
|
|
seaweedfs-volume:
|
|
condition: service_healthy
|
|
volumes:
|
|
- seaweed_data:/data
|
|
ports:
|
|
- "${SEAWEED_FILER_PORT:-8888}:8888"
|
|
healthcheck:
|
|
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:8888/healthz || exit 1"]
|
|
interval: 10s
|
|
timeout: 3s
|
|
retries: 12
|
|
start_period: 15s
|
|
|
|
# --- SeaweedFS: bucket and identity seed ----------------------------------
|
|
# Runs once and exits, before the gateway starts. Two jobs:
|
|
#
|
|
# 1. create the bucket — PocketBase never issues a CreateBucket of its own;
|
|
# 2. write PocketBase's S3 identity into the filer's IAM store.
|
|
#
|
|
# (2) is why this stack does not set AWS_ACCESS_KEY_ID on the gateway, the way
|
|
# docker-compose.seaweedfs.yml does. Those env vars are the *lowest* priority
|
|
# credential source in SeaweedFS: they are read only while the filer's store is
|
|
# empty, so the first identity added in the admin UI would silently displace
|
|
# them and lock PocketBase out. Seeding the store the admin UI itself writes
|
|
# leaves one source of truth, and the key PocketBase uses appears under
|
|
# Object Store → Users like any other.
|
|
#
|
|
# Both commands update in place, so every later boot re-applies the values from
|
|
# .env and changes nothing else — which is also how a rotated PB_S3_SECRET
|
|
# reaches the gateway.
|
|
#
|
|
# The closing grep is the gate: an empty IAM store means the gateway would come
|
|
# up in its allow-anyone default, so this fails loudly instead and the gateway
|
|
# below never starts. No `|| true` here, deliberately.
|
|
seaweedfs-init:
|
|
image: "${SEAWEED_IMAGE:-chrislusf/seaweedfs:4.45}"
|
|
container_name: drivervault-seaweedfs-init
|
|
restart: "no"
|
|
depends_on:
|
|
seaweedfs-filer:
|
|
condition: service_healthy
|
|
environment:
|
|
# Passed as env and expanded by the shell inside the container, so the
|
|
# secret stays out of the container's argv.
|
|
PB_S3_BUCKET: "${PB_S3_BUCKET:-drivervault}"
|
|
PB_S3_ACCESS_KEY: "${PB_S3_ACCESS_KEY:?set PB_S3_ACCESS_KEY in .env}"
|
|
PB_S3_SECRET: "${PB_S3_SECRET:?set PB_S3_SECRET in .env}"
|
|
entrypoint: ["/bin/sh", "-c"]
|
|
command:
|
|
- |
|
|
set -e
|
|
printf '%s\n' \
|
|
"s3.bucket.create -name $$PB_S3_BUCKET" \
|
|
"s3.configure -user drivervault -access_key $$PB_S3_ACCESS_KEY -secret_key $$PB_S3_SECRET -actions Admin -apply" \
|
|
| weed shell -master=seaweedfs-master:9333 -filer=seaweedfs-filer:8888
|
|
echo "s3.configure" \
|
|
| weed shell -master=seaweedfs-master:9333 -filer=seaweedfs-filer:8888 \
|
|
| grep -q "$$PB_S3_ACCESS_KEY"
|
|
|
|
# --- SeaweedFS: S3 gateway -------------------------------------------------
|
|
# The endpoint PocketBase talks to. No -config file: with only -filer given,
|
|
# credentials come from the filer's IAM store, which is what lets the admin UI
|
|
# add and revoke identities without a restart. A config file would take
|
|
# priority over that store and make the admin UI's users inert.
|
|
seaweedfs-s3:
|
|
image: "${SEAWEED_IMAGE:-chrislusf/seaweedfs:4.45}"
|
|
container_name: drivervault-seaweedfs-s3
|
|
restart: unless-stopped
|
|
command: >
|
|
s3 -filer=seaweedfs-filer:8888 -ip.bind=0.0.0.0 -port=8333
|
|
-metricsPort=9327
|
|
depends_on:
|
|
seaweedfs-filer:
|
|
condition: service_healthy
|
|
# Never serve before an identity exists — see seaweedfs-init above.
|
|
seaweedfs-init:
|
|
condition: service_completed_successfully
|
|
# No published port: the stack reaches the gateway over the compose network.
|
|
healthcheck:
|
|
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:8333/healthz || exit 1"]
|
|
interval: 10s
|
|
timeout: 3s
|
|
retries: 12
|
|
start_period: 15s
|
|
|
|
# --- SeaweedFS: admin UI ---------------------------------------------------
|
|
# http://localhost:23646 — cluster topology, volumes, buckets, maintenance
|
|
# tasks, and Object Store → Users, where S3 access keys are minted and revoked.
|
|
# It finds the filer through the master, so -master is all it needs.
|
|
#
|
|
# An unauthenticated panel that can mint credentials for the bucket *is* the
|
|
# bucket, so the password is required rather than defaulted — weed leaves auth
|
|
# off entirely when it is empty. It is read from WEED_ADMIN_* rather than a
|
|
# flag, which keeps it off the process command line. -dataDir persists the
|
|
# session key and the maintenance-task settings.
|
|
seaweedfs-admin:
|
|
image: "${SEAWEED_IMAGE:-chrislusf/seaweedfs:4.45}"
|
|
container_name: drivervault-seaweedfs-admin
|
|
restart: unless-stopped
|
|
command: >
|
|
admin -port=23646 -master=seaweedfs-master:9333 -dataDir=/data
|
|
-metricsPort=9328
|
|
depends_on:
|
|
seaweedfs-master:
|
|
condition: service_healthy
|
|
environment:
|
|
WEED_ADMIN_USER: "${SEAWEED_ADMIN_USER:-admin}"
|
|
WEED_ADMIN_PASSWORD: "${SEAWEED_ADMIN_PASSWORD:?set SEAWEED_ADMIN_PASSWORD in .env}"
|
|
volumes:
|
|
- seaweed_admin:/data
|
|
ports:
|
|
- "${SEAWEED_ADMIN_PORT:-23646}:23646"
|
|
healthcheck:
|
|
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:23646/health || exit 1"]
|
|
interval: 10s
|
|
timeout: 3s
|
|
retries: 12
|
|
start_period: 15s
|
|
|
|
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-s3:
|
|
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 gateway's service name: a server-to-server call inside the compose
|
|
# network.
|
|
PB_S3_ENDPOINT: "http://seaweedfs-s3: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:
|
|
# Shared by master, volume and filer — the same layout `weed server -dir`
|
|
# writes, so this file and docker-compose.seaweedfs.yml can swap places on it.
|
|
seaweed_data:
|
|
# The admin UI's own session key and maintenance-task state. Small, and no
|
|
# part of the object store.
|
|
seaweed_admin:
|