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:
tajniak81
2026-09-04 19:32:19 +02:00
co-authored by Claude Opus 5
parent 181f55a849
commit 9a2a4ab72e
27 changed files with 2467 additions and 123 deletions
-8
View File
@@ -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=
-8
View File
@@ -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=
+121
View File
@@ -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.
+131
View File
@@ -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.
+99
View File
@@ -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.
+100
View File
@@ -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.
-24
View File
@@ -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
View File
@@ -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
+174
View File
@@ -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:
+221
View File
@@ -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:
+174
View File
@@ -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:
+219
View File
@@ -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:
-61
View File
@@ -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: