The files leave the volume the database sits on
Every attachment — a document scan, a fuel receipt, a workshop invoice, a
photo of a part's box — has lived inside pb_data, in a directory beside the
SQLite file. One volume held both, so neither could be sized, backed up or
moved without the other. PocketBase can keep those bytes in an S3 bucket
instead, and now it is told to.
Nothing on the way to a client changes, because an attachment was never a
storage URL to begin with: it is fetched from GET /api/{records}/{id}/file,
which re-checks car access and asks PocketBase for the bytes as the service
account. PocketBase streams from the bucket through that same endpoint rather
than redirecting to it, so the web app, the phone and the plugin cannot tell
which side of the switch they are on.
The bootstrap that already creates the collections now writes PocketBase's
files-storage settings too, from PB_S3_*, on every boot and only when they
differ from what is already there — then asks PocketBase to prove it can reach
the bucket, and says so in the log either way. Two asymmetries are deliberate.
A read of the settings masks the stored secret, so a rotation of the secret
alone is invisible from here and needs another PB_S3_* to move with it. And it
never turns S3 back off: files already written to a bucket are reachable only
while PocketBase still points at it, so dropping the configuration would strand
them rather than undo anything.
Each deployment shape is one compose file with an .env example of the same
name, not a base plus an overlay to remember — six of each per folder, for
Docker and Docker-AIO alike: the plain one, .seaweedfs, .s3, and the three prod
twins. The SeaweedFS files run master, volume, filer and gateway as one process
and a one-shot init container beside it, because PocketBase never issues a
CreateBucket and SeaweedFS will not conjure one on first upload. The credentials
do double duty there — the gateway's only identity is also what PocketBase
authenticates with. In the all-in-one that gateway is a second container rather
than a fourth process under supervisord: keeping the object store inside the
image, on the volume the files are being moved off, would have defeated the
point and would have meant rebuilding.
Files uploaded before the switch are not carried across; PocketBase copies
nothing, and both READMEs say so where an operator will read it.
The TLS overlay and its Caddyfile go. The section they served stays, without
them: nothing in the stack terminates TLS any more, so it now names the four
variables to set in front of whichever proxy already does — TRUST_FORWARDED_PROTO
being the one that decides whether a charger is believed about how it arrived.
Unexercised: this was written on a machine without Docker, so the pinned
SeaweedFS image, the bucket-create and the settings write have not been run
against a live stack. The Go side builds, vets and tests clean.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
181f55a849
commit
9a2a4ab72e
@@ -75,11 +75,3 @@ VITE_API_BASE=
|
||||
# CORS_ALLOW_ORIGINS browser origins allowed to call the API Server directly.
|
||||
# POCKETBASE_URL=http://pocketbase:8070
|
||||
# WEBAPP_URL=http://web-app:8090
|
||||
|
||||
# --- Public hostname + TLS (docker-compose.tls.yml) --------------------------
|
||||
# Only read when the TLS overlay is layered on. DV_DOMAIN must resolve to this
|
||||
# host from the internet, with ports 80 and 443 reaching it; the certificate is
|
||||
# issued automatically on first boot. Setting it also points chargers at
|
||||
# wss://DV_DOMAIN, which is what lets OCPP_REQUIRE_TLS stay on.
|
||||
DV_DOMAIN=
|
||||
DV_ACME_EMAIL=
|
||||
|
||||
@@ -97,11 +97,3 @@ API_BIND=127.0.0.1
|
||||
# stack: the API Server keeps no state on disk, so everything it owns (plugin
|
||||
# settings included) is backed up by backing up this one path.
|
||||
PB_DATA=pb_data
|
||||
|
||||
# --- Public hostname + TLS (docker-compose.tls.yml) --------------------------
|
||||
# Only read when the TLS overlay is layered on. DV_DOMAIN must resolve to this
|
||||
# host from the internet, with ports 80 and 443 reaching it; the certificate is
|
||||
# issued automatically on first boot. Setting it also points chargers at
|
||||
# wss://DV_DOMAIN, which is what lets OCPP_REQUIRE_TLS stay on.
|
||||
DV_DOMAIN=
|
||||
DV_ACME_EMAIL=
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
# DriverVault — production stack config.
|
||||
# Copy to .env and fill in, then:
|
||||
# docker compose -f docker-compose.prod.s3.yml pull
|
||||
# docker compose -f docker-compose.prod.s3.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
|
||||
|
||||
# Schema creation/reconcile on boot. Leave this true: a release can add
|
||||
# collections or fields the server needs, and a stack that skips the bootstrap
|
||||
# never gets them. (The API Server creates app_settings, which holds the plugin
|
||||
# settings, on demand — but only that one.) Set false only for a database you
|
||||
# know already matches the release.
|
||||
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
|
||||
|
||||
# --- EV charging control (Anker Solix, OCPP) ---------------------------------
|
||||
# Only relevant when a charger is set to own/proxy control mode. The charger
|
||||
# dials in to /ocpp/{serial} on the API Server port, carrying its control token
|
||||
# in an OCPP Basic-auth header — which a plaintext ws:// would expose, so
|
||||
# non-TLS connections are rejected by default. Keep the default and terminate
|
||||
# TLS in a reverse proxy in front of this stack, setting OCPP_PUBLIC_URL to the
|
||||
# public wss:// base the charger should be pointed at (deriving it from request
|
||||
# headers is unreliable behind a proxy). Turning the check off is for trusted
|
||||
# networks only.
|
||||
OCPP_REQUIRE_TLS=true
|
||||
OCPP_PUBLIC_URL=
|
||||
|
||||
# Diagnostic only. Set to 1 to log every frame the charger publishes over Anker's
|
||||
# cloud broker — the ones DriverVault decodes and the ones it cannot, with their
|
||||
# bytes. It is how an unnamed frame gets named: hold a control read open, do the
|
||||
# thing in the Anker app, then read the frames back out of the container log.
|
||||
# Leave blank on a normal stack; a triggered charger writes a line every few
|
||||
# seconds.
|
||||
ANKER_MQTT_FRAME_LOG=
|
||||
|
||||
# The charger can dial either door: the API Server port directly, or the Web
|
||||
# App port, whose BFF now proxies /ocpp/ through to it. The endpoint the panel
|
||||
# shows is the API Server port only when OCPP_PUBLIC_URL says so — left blank it
|
||||
# is whichever host the panel itself was reached on, which is the Web App.
|
||||
# TRUST_FORWARDED_PROTO lets the BFF pass an inbound X-Forwarded-Proto to the
|
||||
# API Server: needed when TLS ends at a proxy in front of the stack and
|
||||
# OCPP_REQUIRE_TLS stays on, and unsafe otherwise, since the header is then
|
||||
# whatever the client said it was.
|
||||
TRUST_FORWARDED_PROTO=false
|
||||
|
||||
# --- 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
|
||||
|
||||
# --- Settings the API Server panel can also change ---------------------------
|
||||
# The panel's Settings screens apply these immediately, but only for the life of
|
||||
# the container — the values below are re-applied on every restart and win. Set
|
||||
# them here to make a change permanent.
|
||||
# POCKETBASE_URL where the API Server looks for the database. Defaults to
|
||||
# the bundled pocketbase service; set it to reach one
|
||||
# outside this stack.
|
||||
# WEBAPP_URL the Web App address the panel status page probes. It is
|
||||
# a container-to-container call, so it must be reachable
|
||||
# from the API Server, not from your browser.
|
||||
# CORS_ALLOW_ORIGINS browser origins allowed to call the API Server directly.
|
||||
# POCKETBASE_URL=http://pocketbase:8070
|
||||
# WEBAPP_URL=http://web-app:8090
|
||||
|
||||
# --- Storage -----------------------------------------------------------------
|
||||
# One Docker-managed named volume by default. To store it on a host path
|
||||
# instead, set an absolute path, e.g. PB_DATA=/srv/drivervault/pb_data.
|
||||
# PB_DATA — the PocketBase database and uploads. It is the only volume in the
|
||||
# stack: the API Server keeps no state on disk, so everything it owns (plugin
|
||||
# settings included) is backed up by backing up this one path.
|
||||
PB_DATA=pb_data
|
||||
|
||||
# --- File storage: external S3 -----------------------------------------------
|
||||
# PocketBase keeps its record files — document scans, service and refill
|
||||
# receipts, workshop invoices, part photos — in the bucket below instead of on
|
||||
# PB_DATA. The database and PocketBase's own backups stay where they are.
|
||||
#
|
||||
# Nothing in this stack runs a gateway: both the endpoint and the bucket must
|
||||
# already exist. For a gateway on this Docker host use
|
||||
# http://host.docker.internal:8333 — the compose file adds the host entry that
|
||||
# makes that name resolve inside the containers.
|
||||
PB_S3_ENDPOINT=http://10.2.1.10:8333
|
||||
PB_S3_BUCKET=drivervault
|
||||
PB_S3_ACCESS_KEY=
|
||||
PB_S3_SECRET=
|
||||
# SeaweedFS and MinIO ignore the region; PocketBase insists on having one.
|
||||
PB_S3_REGION=us-east-1
|
||||
# true for SeaweedFS and MinIO, false for AWS S3 proper.
|
||||
PB_S3_FORCE_PATH_STYLE=true
|
||||
|
||||
# Existing uploads are NOT migrated when this is switched on: PocketBase copies
|
||||
# nothing, so attachments made before the switch stop resolving. Read the file
|
||||
# storage section of README.md first.
|
||||
@@ -0,0 +1,131 @@
|
||||
# DriverVault — production stack config.
|
||||
# Copy to .env and fill in, then:
|
||||
# docker compose -f docker-compose.prod.seaweedfs.yml pull
|
||||
# docker compose -f docker-compose.prod.seaweedfs.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
|
||||
|
||||
# Schema creation/reconcile on boot. Leave this true: a release can add
|
||||
# collections or fields the server needs, and a stack that skips the bootstrap
|
||||
# never gets them. (The API Server creates app_settings, which holds the plugin
|
||||
# settings, on demand — but only that one.) Set false only for a database you
|
||||
# know already matches the release.
|
||||
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
|
||||
|
||||
# --- EV charging control (Anker Solix, OCPP) ---------------------------------
|
||||
# Only relevant when a charger is set to own/proxy control mode. The charger
|
||||
# dials in to /ocpp/{serial} on the API Server port, carrying its control token
|
||||
# in an OCPP Basic-auth header — which a plaintext ws:// would expose, so
|
||||
# non-TLS connections are rejected by default. Keep the default and terminate
|
||||
# TLS in a reverse proxy in front of this stack, setting OCPP_PUBLIC_URL to the
|
||||
# public wss:// base the charger should be pointed at (deriving it from request
|
||||
# headers is unreliable behind a proxy). Turning the check off is for trusted
|
||||
# networks only.
|
||||
OCPP_REQUIRE_TLS=true
|
||||
OCPP_PUBLIC_URL=
|
||||
|
||||
# Diagnostic only. Set to 1 to log every frame the charger publishes over Anker's
|
||||
# cloud broker — the ones DriverVault decodes and the ones it cannot, with their
|
||||
# bytes. It is how an unnamed frame gets named: hold a control read open, do the
|
||||
# thing in the Anker app, then read the frames back out of the container log.
|
||||
# Leave blank on a normal stack; a triggered charger writes a line every few
|
||||
# seconds.
|
||||
ANKER_MQTT_FRAME_LOG=
|
||||
|
||||
# The charger can dial either door: the API Server port directly, or the Web
|
||||
# App port, whose BFF now proxies /ocpp/ through to it. The endpoint the panel
|
||||
# shows is the API Server port only when OCPP_PUBLIC_URL says so — left blank it
|
||||
# is whichever host the panel itself was reached on, which is the Web App.
|
||||
# TRUST_FORWARDED_PROTO lets the BFF pass an inbound X-Forwarded-Proto to the
|
||||
# API Server: needed when TLS ends at a proxy in front of the stack and
|
||||
# OCPP_REQUIRE_TLS stays on, and unsafe otherwise, since the header is then
|
||||
# whatever the client said it was.
|
||||
TRUST_FORWARDED_PROTO=false
|
||||
|
||||
# --- 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
|
||||
|
||||
# --- Settings the API Server panel can also change ---------------------------
|
||||
# The panel's Settings screens apply these immediately, but only for the life of
|
||||
# the container — the values below are re-applied on every restart and win. Set
|
||||
# them here to make a change permanent.
|
||||
# POCKETBASE_URL where the API Server looks for the database. Defaults to
|
||||
# the bundled pocketbase service; set it to reach one
|
||||
# outside this stack.
|
||||
# WEBAPP_URL the Web App address the panel status page probes. It is
|
||||
# a container-to-container call, so it must be reachable
|
||||
# from the API Server, not from your browser.
|
||||
# CORS_ALLOW_ORIGINS browser origins allowed to call the API Server directly.
|
||||
# POCKETBASE_URL=http://pocketbase:8070
|
||||
# WEBAPP_URL=http://web-app:8090
|
||||
|
||||
# --- Storage -----------------------------------------------------------------
|
||||
# One Docker-managed named volume by default. To store it on a host path
|
||||
# instead, set an absolute path, e.g. PB_DATA=/srv/drivervault/pb_data.
|
||||
# PB_DATA — the PocketBase database and uploads. It is the only volume in the
|
||||
# stack: the API Server keeps no state on disk, so everything it owns (plugin
|
||||
# settings included) is backed up by backing up this one path.
|
||||
PB_DATA=pb_data
|
||||
|
||||
# --- File storage: SeaweedFS -------------------------------------------------
|
||||
# PocketBase keeps its record files — document scans, service and refill
|
||||
# receipts, workshop invoices, part photos — in the bucket below instead of on
|
||||
# PB_DATA. The database and PocketBase's own backups stay where they are.
|
||||
#
|
||||
# The credentials do double duty: they configure the SeaweedFS gateway's single
|
||||
# identity *and* are what PocketBase authenticates with. There are no safe
|
||||
# defaults, and the stack refuses to start without them.
|
||||
PB_S3_ACCESS_KEY=
|
||||
PB_S3_SECRET=
|
||||
# The bucket. Created on first boot by the seaweedfs-init container.
|
||||
PB_S3_BUCKET=drivervault
|
||||
# SeaweedFS ignores the region; PocketBase insists on having one.
|
||||
PB_S3_REGION=us-east-1
|
||||
|
||||
# SEAWEED_DATA — where SeaweedFS keeps the files. A Docker-managed named volume
|
||||
# by default; set an absolute host path for a bind mount, the same way PB_DATA
|
||||
# works above. Back it up alongside PB_DATA: from here on the attachments live
|
||||
# here, not in the database volume.
|
||||
SEAWEED_DATA=seaweed_data
|
||||
# The gateway image, pinned so a redeploy months from now brings up the same one.
|
||||
# SEAWEED_IMAGE=chrislusf/seaweedfs:4.45
|
||||
# The S3 port is published on loopback only — the stack reaches the gateway over
|
||||
# the compose network, and this is for tools like aws-cli. Set
|
||||
# SEAWEED_S3_BIND=0.0.0.0 to expose it to other hosts, and mean it.
|
||||
# SEAWEED_S3_BIND=127.0.0.1
|
||||
# SEAWEED_S3_PORT=8333
|
||||
|
||||
# Existing uploads are NOT migrated when this is switched on: PocketBase copies
|
||||
# nothing, so attachments made before the switch stop resolving. Read the file
|
||||
# storage section of README.md first.
|
||||
@@ -0,0 +1,99 @@
|
||||
# Copy to .env and fill in. Used by the root docker-compose.s3.yml.
|
||||
|
||||
# --- PocketBase superuser (also used by the API Server to authenticate) ------
|
||||
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 these blank and the
|
||||
# schema is still created but no user is, leaving a stack you cannot log into.
|
||||
DRIVERVAULT_SUPERADMIN_EMAIL=owner@example.com
|
||||
DRIVERVAULT_SUPERADMIN_PASSWORD=change-me-long-password
|
||||
DRIVERVAULT_SUPERADMIN_NAME=Administrator
|
||||
|
||||
# Schema creation/reconcile on boot. Leave this true: a release can add
|
||||
# collections or fields the server needs, and a stack that skips the bootstrap
|
||||
# never gets them. (The API Server creates app_settings, which holds the plugin
|
||||
# settings, on demand — but only that one.) Set false only for a database you
|
||||
# know already matches the release.
|
||||
PB_BOOTSTRAP=true
|
||||
|
||||
# --- API Server -------------------------------------------------------------
|
||||
# Allowed CORS origin(s) for the web app (match WEB_PORT / your public URL).
|
||||
# Native mobile apps are not subject to CORS.
|
||||
CORS_ALLOW_ORIGINS=http://localhost:8090
|
||||
AUTH_USERS_COLLECTION=users
|
||||
|
||||
# --- EV charging control (Anker Solix, OCPP) ---------------------------------
|
||||
# Only relevant when a charger is set to own/proxy control mode. The charger
|
||||
# dials in to /ocpp/{serial} on the API Server port, carrying its control token
|
||||
# in an OCPP Basic-auth header — which a plaintext ws:// would expose, so
|
||||
# non-TLS connections are rejected by default. This dev stack serves plain
|
||||
# HTTP: either terminate TLS in front of it and set OCPP_PUBLIC_URL to the
|
||||
# public wss:// base, or set OCPP_REQUIRE_TLS=false on a trusted network.
|
||||
OCPP_REQUIRE_TLS=true
|
||||
OCPP_PUBLIC_URL=
|
||||
|
||||
# Diagnostic only. Set to 1 to log every frame the charger publishes over Anker's
|
||||
# cloud broker — the ones DriverVault decodes and the ones it cannot, with their
|
||||
# bytes. It is how an unnamed frame gets named: hold a control read open, do the
|
||||
# thing in the Anker app, then read the frames back out of the container log.
|
||||
# Leave blank on a normal stack; a triggered charger writes a line every few
|
||||
# seconds.
|
||||
ANKER_MQTT_FRAME_LOG=
|
||||
|
||||
# The charger can dial either door: the API Server port directly, or the Web
|
||||
# App port, whose BFF now proxies /ocpp/ through to it. The endpoint the panel
|
||||
# shows is the API Server port only when OCPP_PUBLIC_URL says so — left blank it
|
||||
# is whichever host the panel itself was reached on, which is the Web App.
|
||||
# TRUST_FORWARDED_PROTO lets the BFF pass an inbound X-Forwarded-Proto to the
|
||||
# API Server: needed when TLS ends at a proxy in front of the stack and
|
||||
# OCPP_REQUIRE_TLS stays on, and unsafe otherwise, since the header is then
|
||||
# whatever the client said it was.
|
||||
TRUST_FORWARDED_PROTO=false
|
||||
|
||||
# --- Host port mappings (optional; defaults shown) --------------------------
|
||||
PB_PORT=8070
|
||||
API_PORT=8080
|
||||
WEB_PORT=8090
|
||||
|
||||
# --- Web App build -----------------------------------------------------------
|
||||
# Leave empty so the browser uses same-origin /api (proxied by the BFF).
|
||||
VITE_API_BASE=
|
||||
|
||||
# --- Settings the API Server panel can also change ---------------------------
|
||||
# The panel's Settings screens apply these immediately, but only for the life of
|
||||
# the container — the values below are re-applied on every restart and win. Set
|
||||
# them here to make a change permanent.
|
||||
# POCKETBASE_URL where the API Server looks for the database. Defaults to
|
||||
# the bundled pocketbase service; set it to reach one
|
||||
# outside this stack.
|
||||
# WEBAPP_URL the Web App address the panel status page probes. It is
|
||||
# a container-to-container call, so it must be reachable
|
||||
# from the API Server, not from your browser.
|
||||
# CORS_ALLOW_ORIGINS browser origins allowed to call the API Server directly.
|
||||
# POCKETBASE_URL=http://pocketbase:8070
|
||||
# WEBAPP_URL=http://web-app:8090
|
||||
|
||||
# --- File storage: external S3 -----------------------------------------------
|
||||
# PocketBase keeps its record files — document scans, service and refill
|
||||
# receipts, workshop invoices, part photos — in the bucket below instead of on
|
||||
# pb_data. The database and PocketBase's own backups stay where they are.
|
||||
#
|
||||
# Nothing in this stack runs a gateway: both the endpoint and the bucket must
|
||||
# already exist. For a gateway on this Docker host use
|
||||
# http://host.docker.internal:8333 — the compose file adds the host entry that
|
||||
# makes that name resolve inside the containers.
|
||||
PB_S3_ENDPOINT=http://host.docker.internal:8333
|
||||
PB_S3_BUCKET=drivervault
|
||||
PB_S3_ACCESS_KEY=
|
||||
PB_S3_SECRET=
|
||||
# SeaweedFS and MinIO ignore the region; PocketBase insists on having one.
|
||||
PB_S3_REGION=us-east-1
|
||||
# true for SeaweedFS and MinIO, false for AWS S3 proper.
|
||||
PB_S3_FORCE_PATH_STYLE=true
|
||||
|
||||
# Existing uploads are NOT migrated when this is switched on: PocketBase copies
|
||||
# nothing, so attachments made before the switch stop resolving. Read the file
|
||||
# storage section of README.md first.
|
||||
@@ -0,0 +1,100 @@
|
||||
# Copy to .env and fill in. Used by the root docker-compose.seaweedfs.yml.
|
||||
|
||||
# --- PocketBase superuser (also used by the API Server to authenticate) ------
|
||||
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 these blank and the
|
||||
# schema is still created but no user is, leaving a stack you cannot log into.
|
||||
DRIVERVAULT_SUPERADMIN_EMAIL=owner@example.com
|
||||
DRIVERVAULT_SUPERADMIN_PASSWORD=change-me-long-password
|
||||
DRIVERVAULT_SUPERADMIN_NAME=Administrator
|
||||
|
||||
# Schema creation/reconcile on boot. Leave this true: a release can add
|
||||
# collections or fields the server needs, and a stack that skips the bootstrap
|
||||
# never gets them. (The API Server creates app_settings, which holds the plugin
|
||||
# settings, on demand — but only that one.) Set false only for a database you
|
||||
# know already matches the release.
|
||||
PB_BOOTSTRAP=true
|
||||
|
||||
# --- API Server -------------------------------------------------------------
|
||||
# Allowed CORS origin(s) for the web app (match WEB_PORT / your public URL).
|
||||
# Native mobile apps are not subject to CORS.
|
||||
CORS_ALLOW_ORIGINS=http://localhost:8090
|
||||
AUTH_USERS_COLLECTION=users
|
||||
|
||||
# --- EV charging control (Anker Solix, OCPP) ---------------------------------
|
||||
# Only relevant when a charger is set to own/proxy control mode. The charger
|
||||
# dials in to /ocpp/{serial} on the API Server port, carrying its control token
|
||||
# in an OCPP Basic-auth header — which a plaintext ws:// would expose, so
|
||||
# non-TLS connections are rejected by default. This dev stack serves plain
|
||||
# HTTP: either terminate TLS in front of it and set OCPP_PUBLIC_URL to the
|
||||
# public wss:// base, or set OCPP_REQUIRE_TLS=false on a trusted network.
|
||||
OCPP_REQUIRE_TLS=true
|
||||
OCPP_PUBLIC_URL=
|
||||
|
||||
# Diagnostic only. Set to 1 to log every frame the charger publishes over Anker's
|
||||
# cloud broker — the ones DriverVault decodes and the ones it cannot, with their
|
||||
# bytes. It is how an unnamed frame gets named: hold a control read open, do the
|
||||
# thing in the Anker app, then read the frames back out of the container log.
|
||||
# Leave blank on a normal stack; a triggered charger writes a line every few
|
||||
# seconds.
|
||||
ANKER_MQTT_FRAME_LOG=
|
||||
|
||||
# The charger can dial either door: the API Server port directly, or the Web
|
||||
# App port, whose BFF now proxies /ocpp/ through to it. The endpoint the panel
|
||||
# shows is the API Server port only when OCPP_PUBLIC_URL says so — left blank it
|
||||
# is whichever host the panel itself was reached on, which is the Web App.
|
||||
# TRUST_FORWARDED_PROTO lets the BFF pass an inbound X-Forwarded-Proto to the
|
||||
# API Server: needed when TLS ends at a proxy in front of the stack and
|
||||
# OCPP_REQUIRE_TLS stays on, and unsafe otherwise, since the header is then
|
||||
# whatever the client said it was.
|
||||
TRUST_FORWARDED_PROTO=false
|
||||
|
||||
# --- Host port mappings (optional; defaults shown) --------------------------
|
||||
PB_PORT=8070
|
||||
API_PORT=8080
|
||||
WEB_PORT=8090
|
||||
|
||||
# --- Web App build -----------------------------------------------------------
|
||||
# Leave empty so the browser uses same-origin /api (proxied by the BFF).
|
||||
VITE_API_BASE=
|
||||
|
||||
# --- Settings the API Server panel can also change ---------------------------
|
||||
# The panel's Settings screens apply these immediately, but only for the life of
|
||||
# the container — the values below are re-applied on every restart and win. Set
|
||||
# them here to make a change permanent.
|
||||
# POCKETBASE_URL where the API Server looks for the database. Defaults to
|
||||
# the bundled pocketbase service; set it to reach one
|
||||
# outside this stack.
|
||||
# WEBAPP_URL the Web App address the panel status page probes. It is
|
||||
# a container-to-container call, so it must be reachable
|
||||
# from the API Server, not from your browser.
|
||||
# CORS_ALLOW_ORIGINS browser origins allowed to call the API Server directly.
|
||||
# POCKETBASE_URL=http://pocketbase:8070
|
||||
# WEBAPP_URL=http://web-app:8090
|
||||
|
||||
# --- File storage: SeaweedFS -------------------------------------------------
|
||||
# PocketBase keeps its record files — document scans, service and refill
|
||||
# receipts, workshop invoices, part photos — in the bucket below instead of on
|
||||
# pb_data. The database and PocketBase's own backups stay where they are.
|
||||
#
|
||||
# The credentials do double duty: they configure the SeaweedFS gateway's single
|
||||
# identity *and* are what PocketBase authenticates with. There are no safe
|
||||
# defaults, and the stack refuses to start without them.
|
||||
PB_S3_ACCESS_KEY=
|
||||
PB_S3_SECRET=
|
||||
# The bucket. Created on first boot by the seaweedfs-init container.
|
||||
PB_S3_BUCKET=drivervault
|
||||
# SeaweedFS ignores the region; PocketBase insists on having one.
|
||||
PB_S3_REGION=us-east-1
|
||||
# The gateway image, pinned so a rebuild months from now brings up the same one.
|
||||
# SEAWEED_IMAGE=chrislusf/seaweedfs:4.45
|
||||
# Host port for the S3 API, so aws-cli and friends can reach it while developing.
|
||||
# SEAWEED_S3_PORT=8333
|
||||
|
||||
# Existing uploads are NOT migrated when this is switched on: PocketBase copies
|
||||
# nothing, so attachments made before the switch stop resolving. Read the file
|
||||
# storage section of README.md first.
|
||||
@@ -1,24 +0,0 @@
|
||||
# Caddy in front of the stack: one hostname, TLS from Let's Encrypt, everything
|
||||
# behind it spoken to over the compose network in plain HTTP.
|
||||
#
|
||||
# Both doors are the same door here. Browsers get the Web App; chargers dial
|
||||
# /ocpp/{serial} on the same hostname, and the BFF carries that through to the
|
||||
# API Server. Caddy proxies WebSocket upgrades without being told to, so there
|
||||
# is nothing to configure for the charger case.
|
||||
#
|
||||
# DV_DOMAIN and DV_ACME_EMAIL come from .env via docker-compose.tls.yml.
|
||||
|
||||
{
|
||||
email {$DV_ACME_EMAIL}
|
||||
}
|
||||
|
||||
{$DV_DOMAIN} {
|
||||
encode zstd gzip
|
||||
|
||||
# X-Forwarded-Proto is set by Caddy to the scheme the client used. The API
|
||||
# Server reads it to decide a charger arrived over TLS, which is why the
|
||||
# web-app service is given TRUST_FORWARDED_PROTO=true — without that the BFF
|
||||
# would overwrite this header with its own plaintext hop and a charger would
|
||||
# be rejected under OCPP_REQUIRE_TLS.
|
||||
reverse_proxy web-app:8090
|
||||
}
|
||||
+76
-22
@@ -78,6 +78,68 @@ instead. PocketBase runs as root, so a root-owned host directory is fine.
|
||||
> `WEBAPP_URL` and `CORS_ALLOW_ORIGINS` in `.env` to change them permanently —
|
||||
> in this stack the compose environment wins over anything the panel writes.
|
||||
|
||||
## File storage (SeaweedFS / S3)
|
||||
|
||||
Uploaded files — document scans, service and refill receipts, workshop invoices,
|
||||
part photos — live inside `pb_data` by default, next to the database. Two further
|
||||
compose files put them in an S3 bucket instead, so the blobs and the database can
|
||||
be sized, backed up and moved independently. Nothing else changes: an attachment has
|
||||
always been fetched through the API Server (`GET /api/service-records/{id}/file`),
|
||||
never from a storage URL, so the Web App, the phone app and the Home Assistant
|
||||
plugin cannot tell the difference.
|
||||
|
||||
Each shape is one self-contained compose file — nothing to layer, nothing to
|
||||
remember — with an `.env` example of the same name:
|
||||
|
||||
| Shape | From the registry | From source |
|
||||
|---|---|---|
|
||||
| **Local storage** — the default, unchanged | `docker-compose.prod.yml` | `docker-compose.yml` |
|
||||
| **SeaweedFS in this stack** | `docker-compose.prod.seaweedfs.yml` | `docker-compose.seaweedfs.yml` |
|
||||
| **An S3 endpoint outside it** | `docker-compose.prod.s3.yml` | `docker-compose.s3.yml` |
|
||||
|
||||
So `docker-compose.prod.seaweedfs.yml` is configured from
|
||||
`.env.prod.seaweedfs.example`, `docker-compose.s3.yml` from `.env.s3.example`,
|
||||
and so on:
|
||||
|
||||
```sh
|
||||
cp .env.prod.seaweedfs.example .env # then edit it — PB_S3_* have no defaults
|
||||
docker compose -f docker-compose.prod.seaweedfs.yml pull
|
||||
docker compose -f docker-compose.prod.seaweedfs.yml up -d
|
||||
```
|
||||
|
||||
Set `PB_S3_ACCESS_KEY` and `PB_S3_SECRET` first — both storage files refuse to
|
||||
start without them. The SeaweedFS ones add a `seaweedfs` container (master,
|
||||
volume, filer and S3 gateway in one process, on its own `seaweed_data` volume)
|
||||
plus a one-shot `seaweedfs-init` that creates the bucket, because PocketBase never
|
||||
issues a `CreateBucket` of its own. The external-S3 ones add no containers at
|
||||
all: set `PB_S3_ENDPOINT`, and create the bucket yourself.
|
||||
|
||||
On every boot the API Server's bootstrap writes PocketBase's *Files storage*
|
||||
settings from those variables, then asks PocketBase to prove it can reach the
|
||||
bucket. Watch for it in the log:
|
||||
|
||||
```
|
||||
[api] bootstrap: ✓ file storage → S3 (drivervault at http://seaweedfs:8333)
|
||||
[api] bootstrap: ✓ S3 storage reachable
|
||||
```
|
||||
|
||||
A boot that finds the settings already correct logs `• file storage already on S3`
|
||||
and writes nothing.
|
||||
|
||||
Two things to know before turning it on:
|
||||
|
||||
- **Existing files are not migrated.** PocketBase copies nothing when the setting
|
||||
flips, so attachments uploaded before the switch stop resolving. Copy
|
||||
`pb_data/storage/<collectionId>/<recordId>/<file>` into the bucket root, keeping
|
||||
that layout, *before* enabling it — or start from a stack with no attachments.
|
||||
- **Going back to the plain compose file is not an off switch.** It leaves
|
||||
PocketBase pointed at
|
||||
the bucket, deliberately: files already written there are reachable only while
|
||||
it is. Move them back and turn it off in PocketBase's own admin UI. For the same
|
||||
reason a rotation of `PB_S3_SECRET` alone is invisible to the bootstrap —
|
||||
PocketBase masks the stored secret on read — so change another `PB_S3_*` value
|
||||
alongside it, or set it in the admin UI.
|
||||
|
||||
## Charger control (OCPP)
|
||||
|
||||
Chargers in own/proxy mode dial in to `/ocpp/{serial}`, authenticating with a
|
||||
@@ -93,34 +155,26 @@ A plaintext `ws://` puts the control token on the wire in the clear, so
|
||||
|
||||
### With a public hostname and TLS
|
||||
|
||||
`docker-compose.tls.yml` adds Caddy in front of the stack: one hostname, a
|
||||
certificate issued on first boot, and everything behind it spoken to over the
|
||||
compose network. Browsers and chargers arrive at the same name.
|
||||
Nothing in this stack terminates TLS. Put your own reverse proxy in front of the
|
||||
Web App port, give it a certificate and a hostname, and set four things by hand:
|
||||
|
||||
```sh
|
||||
# in .env
|
||||
DV_DOMAIN=drivervault.example.com
|
||||
DV_ACME_EMAIL=you@example.com
|
||||
|
||||
docker compose -f docker-compose.prod.yml -f docker-compose.tls.yml up -d
|
||||
OCPP_PUBLIC_URL=wss://drivervault.example.com
|
||||
OCPP_REQUIRE_TLS=true
|
||||
CORS_ALLOW_ORIGINS=https://drivervault.example.com
|
||||
TRUST_FORWARDED_PROTO=true
|
||||
```
|
||||
|
||||
The overlay sets the rest for you: `OCPP_PUBLIC_URL=wss://$DV_DOMAIN`,
|
||||
`OCPP_REQUIRE_TLS=true`, `CORS_ALLOW_ORIGINS=https://$DV_DOMAIN`, and
|
||||
`TRUST_FORWARDED_PROTO=true` on the Web App so the BFF passes Caddy's
|
||||
`X-Forwarded-Proto` to the API Server instead of overwriting it with its own
|
||||
plaintext hop. Point the charger's OCPP backend at the endpoint the panel then
|
||||
shows, with the control token as its authorization key.
|
||||
`TRUST_FORWARDED_PROTO` is the easy one to miss: without it the Web App's BFF
|
||||
overwrites the proxy's `X-Forwarded-Proto` with its own plaintext hop and every
|
||||
charger is rejected as insecure. Only set it when that proxy really is the only
|
||||
way in — otherwise a charger could claim `wss` over a plaintext connection.
|
||||
|
||||
Two things the overlay cannot arrange: `DV_DOMAIN` must resolve to the host from
|
||||
the internet with ports 80 and 443 reaching it (Caddy needs `:80` for the ACME
|
||||
challenge), and the charger must be able to resolve that name too — behind NAT
|
||||
that usually means hairpin NAT or a split-DNS entry pointing it at the LAN
|
||||
address.
|
||||
|
||||
Using a proxy you already run instead? Terminate TLS there, forward to the Web
|
||||
App port, and set the same four variables by hand — the `X-Forwarded-Proto` one
|
||||
included, or chargers will be rejected as insecure.
|
||||
Point the charger's OCPP backend at the endpoint the panel then shows, with the
|
||||
control token as its authorization key. The charger has to resolve that hostname
|
||||
too: behind NAT that usually means hairpin NAT, or a split-DNS entry pointing the
|
||||
name at the LAN address.
|
||||
|
||||
## Notes
|
||||
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
name: drivervault
|
||||
|
||||
# Production DriverVault stack, with external S3 — pulls prebuilt images from
|
||||
# the registry instead of building from source. Self-contained: one file, no
|
||||
# overlays. Everything an operator needs to set lives in .env.
|
||||
#
|
||||
# 1. cp .env.prod.s3.example .env (then edit it — PB_S3_* especially)
|
||||
# 2. docker compose -f docker-compose.prod.s3.yml pull
|
||||
# 3. docker compose -f docker-compose.prod.s3.yml up -d
|
||||
#
|
||||
# This is docker-compose.prod.yml pointed at an S3 endpoint that already exists
|
||||
# somewhere else — its own host, another compose project, or any S3-compatible
|
||||
# service. PocketBase keeps its record files — document scans, service and
|
||||
# refill receipts, workshop invoices, part photos — in that bucket instead of on
|
||||
# the pb_data volume. 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.
|
||||
#
|
||||
# The bucket must already exist, and nothing here runs the gateway. For a
|
||||
# SeaweedFS that comes up with the stack, use docker-compose.prod.seaweedfs.yml.
|
||||
#
|
||||
# Before turning this on for a stack that already has uploads: PocketBase does
|
||||
# NOT copy existing files into the bucket. See README.md.
|
||||
#
|
||||
# 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
|
||||
extra_hosts:
|
||||
# Lets PB_S3_ENDPOINT name the Docker host as host.docker.internal.
|
||||
# Harmless when the endpoint is somewhere else entirely.
|
||||
- "host.docker.internal:host-gateway"
|
||||
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
|
||||
extra_hosts:
|
||||
# Lets PB_S3_ENDPOINT name the Docker host as host.docker.internal.
|
||||
# Harmless when the endpoint is somewhere else entirely.
|
||||
- "host.docker.internal:host-gateway"
|
||||
depends_on:
|
||||
pocketbase:
|
||||
condition: service_healthy
|
||||
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}"
|
||||
CORS_ALLOW_ORIGINS: "${CORS_ALLOW_ORIGINS:-http://localhost:8090}"
|
||||
AUTH_USERS_COLLECTION: "${AUTH_USERS_COLLECTION:-users}"
|
||||
# Schema + super-admin bootstrap (idempotent). Leave this ON: a release can
|
||||
# add collections or fields the server needs, and a stack that skips the
|
||||
# bootstrap 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 already 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
|
||||
# reach the server over TLS. Behind a TLS-terminating reverse proxy, set
|
||||
# OCPP_PUBLIC_URL to the public wss:// base and API_BIND so the proxy can
|
||||
# reach this port; only drop OCPP_REQUIRE_TLS 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. The bucket must already exist: nothing here
|
||||
# creates it.
|
||||
PB_S3_ENABLED: "true"
|
||||
PB_S3_BUCKET: "${PB_S3_BUCKET:-drivervault}"
|
||||
PB_S3_ENDPOINT: "${PB_S3_ENDPOINT:?set PB_S3_ENDPOINT in .env}"
|
||||
PB_S3_REGION: "${PB_S3_REGION:-us-east-1}"
|
||||
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}"
|
||||
# true for SeaweedFS and MinIO, false for AWS S3 proper.
|
||||
PB_S3_FORCE_PATH_STYLE: "${PB_S3_FORCE_PATH_STYLE:-true}"
|
||||
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 — and the
|
||||
# /ocpp/{serial} endpoint chargers dial into — on the network.
|
||||
- "${API_BIND:-127.0.0.1}:${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:
|
||||
image: "${WEB_IMAGE:-10.2.1.10:5500/admin/drivervault-web-app:latest}"
|
||||
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:
|
||||
# The public front door. Bound on all interfaces so browsers can reach it.
|
||||
- "${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:
|
||||
@@ -0,0 +1,221 @@
|
||||
name: drivervault
|
||||
|
||||
# Production DriverVault stack, with SeaweedFS — pulls prebuilt images from the
|
||||
# registry instead of building from source. Self-contained: one file, no
|
||||
# overlays. Everything an operator needs to set lives in .env.
|
||||
#
|
||||
# 1. cp .env.prod.seaweedfs.example .env (then edit it)
|
||||
# 2. docker compose -f docker-compose.prod.seaweedfs.yml pull
|
||||
# 3. docker compose -f docker-compose.prod.seaweedfs.yml up -d
|
||||
#
|
||||
# This is docker-compose.prod.yml plus an S3 object store: 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.
|
||||
#
|
||||
# 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:
|
||||
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:
|
||||
# Named volume by default; set SEAWEED_DATA to a host path in .env for a
|
||||
# bind mount, exactly as PB_DATA works. Back it up alongside PB_DATA —
|
||||
# from here on the attachments live here, not in the database volume.
|
||||
- "${SEAWEED_DATA:-seaweed_data}:/data"
|
||||
ports:
|
||||
# Loopback only: the stack reaches the gateway over the compose network,
|
||||
# so this is here for `aws s3 ls --endpoint-url http://127.0.0.1:8333` and
|
||||
# nothing else. Set SEAWEED_S3_BIND=0.0.0.0 to expose it, and mean it.
|
||||
- "${SEAWEED_S3_BIND:-127.0.0.1}:${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:
|
||||
image: "${PB_IMAGE:-10.2.1.10:5500/admin/drivervault-pocketbase:latest}"
|
||||
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:
|
||||
# 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
|
||||
# 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}"
|
||||
CORS_ALLOW_ORIGINS: "${CORS_ALLOW_ORIGINS:-http://localhost:8090}"
|
||||
AUTH_USERS_COLLECTION: "${AUTH_USERS_COLLECTION:-users}"
|
||||
# Schema + super-admin bootstrap (idempotent). Leave this ON: a release can
|
||||
# add collections or fields the server needs, and a stack that skips the
|
||||
# bootstrap 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 already 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
|
||||
# reach the server over TLS. Behind a TLS-terminating reverse proxy, set
|
||||
# OCPP_PUBLIC_URL to the public wss:// base and API_BIND so the proxy can
|
||||
# reach this port; only drop OCPP_REQUIRE_TLS 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:
|
||||
# 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 — and the
|
||||
# /ocpp/{serial} endpoint chargers dial into — on the network.
|
||||
- "${API_BIND:-127.0.0.1}:${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:
|
||||
image: "${WEB_IMAGE:-10.2.1.10:5500/admin/drivervault-web-app:latest}"
|
||||
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:
|
||||
# The public front door. Bound on all interfaces so browsers can reach it.
|
||||
- "${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:
|
||||
@@ -0,0 +1,174 @@
|
||||
name: drivervault
|
||||
|
||||
# Full DriverVault stack, with external S3: PocketBase (database) + API Server +
|
||||
# Web App, built from source, storing files in an S3 endpoint that already
|
||||
# exists somewhere else. Self-contained — one file, nothing to layer.
|
||||
#
|
||||
# cp .env.s3.example .env (then edit it — PB_S3_* especially)
|
||||
# docker compose -f docker-compose.s3.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 that 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.
|
||||
#
|
||||
# The bucket must already exist, and nothing here runs the gateway. For a
|
||||
# SeaweedFS that comes up with the stack, use docker-compose.seaweedfs.yml.
|
||||
#
|
||||
# Before turning this on for a stack that already has uploads: PocketBase does
|
||||
# NOT copy existing files into the bucket. See README.md.
|
||||
|
||||
services:
|
||||
pocketbase:
|
||||
build:
|
||||
context: ./pocketbase
|
||||
image: drivervault-pocketbase
|
||||
container_name: drivervault-pocketbase
|
||||
restart: unless-stopped
|
||||
extra_hosts:
|
||||
# Lets PB_S3_ENDPOINT name the Docker host as host.docker.internal.
|
||||
# Harmless when the endpoint is somewhere else entirely.
|
||||
- "host.docker.internal:host-gateway"
|
||||
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
|
||||
extra_hosts:
|
||||
# Lets PB_S3_ENDPOINT name the Docker host as host.docker.internal.
|
||||
# Harmless when the endpoint is somewhere else entirely.
|
||||
- "host.docker.internal:host-gateway"
|
||||
depends_on:
|
||||
pocketbase:
|
||||
condition: service_healthy
|
||||
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. The bucket must already exist: nothing here
|
||||
# creates it.
|
||||
PB_S3_ENABLED: "true"
|
||||
PB_S3_BUCKET: "${PB_S3_BUCKET:-drivervault}"
|
||||
PB_S3_ENDPOINT: "${PB_S3_ENDPOINT:?set PB_S3_ENDPOINT in .env}"
|
||||
PB_S3_REGION: "${PB_S3_REGION:-us-east-1}"
|
||||
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}"
|
||||
# true for SeaweedFS and MinIO, false for AWS S3 proper.
|
||||
PB_S3_FORCE_PATH_STYLE: "${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:
|
||||
@@ -0,0 +1,219 @@
|
||||
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:
|
||||
@@ -1,61 +0,0 @@
|
||||
name: drivervault
|
||||
|
||||
# TLS overlay — put a public hostname and a real certificate in front of the
|
||||
# stack. Layer it on top of either base file:
|
||||
#
|
||||
# docker compose -f docker-compose.prod.yml -f docker-compose.tls.yml up -d
|
||||
#
|
||||
# What it changes:
|
||||
# • Caddy terminates TLS on :443 and proxies everything to the Web App BFF,
|
||||
# which already carries /api/ and /ocpp/ through to the API Server. One
|
||||
# hostname serves browsers and chargers alike.
|
||||
# • The charger endpoint the panel hands out becomes wss://$DV_DOMAIN/ocpp/…,
|
||||
# so OCPP_REQUIRE_TLS goes back on and the control token stops crossing the
|
||||
# network in the clear.
|
||||
#
|
||||
# Requirements, none of which this file can arrange for you:
|
||||
# • DV_DOMAIN resolves to this host from the public internet, and ports 80 and
|
||||
# 443 reach it (Caddy needs :80 for the ACME challenge, and keeps it for the
|
||||
# redirect afterwards).
|
||||
# • The charger can resolve DV_DOMAIN too. On a LAN behind NAT that usually
|
||||
# means hairpin NAT or a split-DNS entry pointing the name at 10.2.1.10 —
|
||||
# otherwise the charger looks up a public address it cannot route to.
|
||||
|
||||
services:
|
||||
caddy:
|
||||
image: "${CADDY_IMAGE:-caddy:2-alpine}"
|
||||
container_name: drivervault-caddy
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
web-app:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
DV_DOMAIN: "${DV_DOMAIN:?set DV_DOMAIN in .env}"
|
||||
# Let's Encrypt sends expiry warnings here if renewal ever stops working.
|
||||
DV_ACME_EMAIL: "${DV_ACME_EMAIL:?set DV_ACME_EMAIL in .env}"
|
||||
ports:
|
||||
- "80:80"
|
||||
- "443:443"
|
||||
volumes:
|
||||
- ./Caddyfile:/etc/caddy/Caddyfile:ro
|
||||
# Certificates live here. Keep the volume: wiping it re-issues on next
|
||||
# boot and Let's Encrypt rate-limits that.
|
||||
- caddy_data:/data
|
||||
- caddy_config:/config
|
||||
|
||||
api-server:
|
||||
environment:
|
||||
# Derived from the one hostname, so there is a single thing to set.
|
||||
OCPP_REQUIRE_TLS: "true"
|
||||
OCPP_PUBLIC_URL: "wss://${DV_DOMAIN}"
|
||||
CORS_ALLOW_ORIGINS: "https://${DV_DOMAIN}"
|
||||
|
||||
web-app:
|
||||
environment:
|
||||
# Caddy terminates TLS and says so in X-Forwarded-Proto. Believe it —
|
||||
# that header is now set by the proxy in front, not by whoever dialed in.
|
||||
TRUST_FORWARDED_PROTO: "true"
|
||||
|
||||
volumes:
|
||||
caddy_data:
|
||||
caddy_config:
|
||||
Reference in New Issue
Block a user