Dockerfile.seaweedfs bakes `weed server -s3` into the all-in-one as a fourth supervisord program, sharing every build stage and the nginx config with Dockerfile.seaweedfs.split. The gateway's single identity is mapped from the PB_S3_* pair PocketBase already authenticates with, and an empty pair refuses to start rather than serving allow-anyone. The seaweedfs-init container becomes a step in PocketBase's program — wait for the gateway, create the bucket, then serve — so the bucket exists before the API Server's bootstrap checks it, in the order compose kept with depends_on. The volume server moves to 8081 because 8080 is the API Server; weed server has one bind flag for all four roles, so it stays open and the comment says never to publish it. docker-compose.prod.seaweedfs.yml goes from three services to one, with build: and image: both set under a registry name of its own. The env example and README follow. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
237 lines
12 KiB
Markdown
237 lines
12 KiB
Markdown
# DriverVault — Docker AIO (all-in-one image)
|
|
|
|
**PocketBase + API Server + Web App in a single container**, supervised by
|
|
`supervisord` with nginx serving the SPA and proxying `/api/` to the API Server
|
|
on localhost. One image, one volume set, no compose network — the simplest way
|
|
to stand DriverVault up on a single host.
|
|
|
|
Prefer the three-container stack in [`../Docker`](../Docker) when you want to
|
|
scale, upgrade or restart the pieces independently.
|
|
|
|
```
|
|
:80 nginx ─► SPA, and /api/ ─► API Server on 127.0.0.1:8080 ─► PocketBase on :8070
|
|
```
|
|
|
|
| File | Use |
|
|
|---|---|
|
|
| `Dockerfile` | the all-in-one image (build context must be the **repo root**) |
|
|
| `Dockerfile.seaweedfs` | the same, with SeaweedFS (`weed server -s3`) baked in — see below |
|
|
| `Dockerfile.seaweedfs.split` | the same, with SeaweedFS split into its roles baked in — see below |
|
|
| `docker-compose.yml` | **builds from source** — for development and local testing |
|
|
| `docker-compose.prod.yml` | **pulls the prebuilt image** from the registry |
|
|
| `.env.example` / `.env.prod.example` | copy to `.env` for the matching compose file |
|
|
|
|
## Run it
|
|
|
|
```bash
|
|
cd "Docker-AIO"
|
|
cp .env.example .env # then edit — PB_ADMIN_* have no safe defaults
|
|
docker compose up -d --build
|
|
```
|
|
|
|
Production, from the registry:
|
|
|
|
```bash
|
|
cp .env.prod.example .env # then edit
|
|
docker compose -f docker-compose.prod.yml pull
|
|
docker compose -f docker-compose.prod.yml up -d
|
|
```
|
|
|
|
Then: web app on `http://host:8090/`, the API Server's superadmin panel on
|
|
`http://host:8080/`, PocketBase admin on `http://host:8070/_/`.
|
|
|
|
Note the port mapping: inside the container the web app is on **80**, published
|
|
as `WEB_PORT` (8090 by default) to line up with the other deployment.
|
|
|
|
## Building by hand
|
|
|
|
The build context **must be the repo root** so the Dockerfile can reach both
|
|
`API Server/` and `Web App/`:
|
|
|
|
```bash
|
|
docker build -f "Docker-AIO/Dockerfile" -t drivervault-aio .
|
|
```
|
|
|
|
Build args: `VITE_API_BASE` (leave empty so the bundle uses same-origin `/api`)
|
|
and `PB_VERSION`, which the Dockerfile already pins. Override it to build a
|
|
different PocketBase; set it to an empty string to resolve the latest release at
|
|
build time instead.
|
|
|
|
## First boot
|
|
|
|
Identical to the multi-container stack, and idempotent:
|
|
|
|
1. PocketBase upserts its superuser from `PB_ADMIN_EMAIL` / `PB_ADMIN_PASSWORD`.
|
|
2. The API Server waits for PocketBase to report healthy, then creates any
|
|
missing collections, reconciles existing ones, and creates the first app
|
|
`superadmin` from `DRIVERVAULT_SUPERADMIN_EMAIL` / `_PASSWORD`.
|
|
|
|
> Leave `PB_BOOTSTRAP` at `true`, including across upgrades. A release can add
|
|
> collections or fields the server needs, and a stack that skipped 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, so turn it off only for a database you know already
|
|
> matches the release you are running.
|
|
|
|
## Volumes
|
|
|
|
| Volume | Holds |
|
|
|---|---|
|
|
| `/pb/pb_data` | the PocketBase SQLite database and uploaded files — everything that persists |
|
|
|
|
One volume, because the API Server keeps no state on disk. Plugin enable-state
|
|
and global config live in the database like the rest of the settings, so backing
|
|
up this one path backs up the whole image. It defaults to a Docker-managed named
|
|
volume; set `PB_DATA` to an absolute host path in the prod file for a bind mount.
|
|
|
|
> One thing does **not** persist: the API Server panel's *Settings → PocketBase*
|
|
> and *Settings → Web App* screens apply immediately but only for the life of the
|
|
> container. Set the corresponding environment variables to change them
|
|
> permanently.
|
|
|
|
## 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 beside the image** | `docker-compose.prod.seaweedfs.yml` (builds `Dockerfile.seaweedfs`) | `docker-compose.seaweedfs.yml` |
|
|
| **SeaweedFS, split into its roles** | `docker-compose.prod.seaweedfs.split.yml` (builds `Dockerfile.seaweedfs.split`) | `docker-compose.seaweedfs.split.yml` |
|
|
| **An S3 endpoint elsewhere** | `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 from-source SeaweedFS files run the gateway as a
|
|
**second container** (master, volume, filer and S3 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 prod SeaweedFS
|
|
files go the other way and **build it into the image**: `Dockerfile.seaweedfs`
|
|
runs `weed server -s3` as a fourth supervisord program (the `weed` binary is
|
|
copied from the official image, pinned by the `SEAWEED_VERSION` build arg), on
|
|
a `/seaweed/data` volume of its own, with PocketBase's program creating the
|
|
bucket before it serves. One container, two volumes; the cost is that a new
|
|
SeaweedFS is a rebuild rather than a tag change. Inside, the volume server sits
|
|
on **8081** since 8080 is the API Server, and only the gateway is published, on
|
|
loopback by default. The build needs the repo checkout, so either build on the
|
|
deploy host or `docker compose push` the image to `AIO_IMAGE` and `pull` it
|
|
there. The external-S3 ones add no containers at all: set `PB_S3_ENDPOINT`, and
|
|
create the bucket yourself.
|
|
|
|
### Split SeaweedFS
|
|
|
|
`weed server -s3` runs master, volume, filer and gateway as four goroutines in
|
|
one process. `docker-compose.seaweedfs.split.yml` runs them as four containers
|
|
beside the all-in-one, plus a fifth: the SeaweedFS **admin UI** on port 23646,
|
|
where the cluster can be inspected and — under *Object Store → Users* — further
|
|
S3 identities minted and revoked. Split also gets you per-role restarts and
|
|
upgrades, per-role Prometheus metrics, and room to add a second volume server
|
|
later.
|
|
|
|
The prod twin, like its non-split sibling, builds it in:
|
|
`docker-compose.prod.seaweedfs.split.yml` **builds `Dockerfile.seaweedfs.split`**,
|
|
which bakes all five roles into the all-in-one image as five more supervisord
|
|
programs. One container, three volumes (`PB_DATA`, `SEAWEED_DATA`,
|
|
`SEAWEED_ADMIN_DATA`), and the bucket-and-identity seed runs inside the
|
|
gateway's program instead of a `seaweedfs-init` container. What survives of
|
|
"split" in there is per-role restart, per-role metrics ports and the admin UI;
|
|
what does not is spreading the roles over hosts. Inside, every role listens on
|
|
loopback, and only the gateway and the admin UI are published, on loopback by
|
|
default.
|
|
|
|
Identities work differently there, and it matters. SeaweedFS reads credentials
|
|
from, in descending priority: an `-s3.config` file, the filer's IAM store, then
|
|
`AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY` — and a higher source *replaces* a
|
|
lower one rather than adding to it. The single-gateway files use the env vars,
|
|
which is why nothing else may write identities there: the first user added in a
|
|
panel would displace PocketBase's key. So in the split files `seaweedfs-init`
|
|
seeds PocketBase's identity into the filer's store instead — the same store the
|
|
admin UI writes — and the gateway runs with no config file at all. One source of
|
|
truth, PocketBase's key visible in the panel beside every other, and new keys
|
|
picked up without a restart. Rotating `PB_S3_SECRET` in `.env` and restarting
|
|
updates that identity in place.
|
|
|
|
Set `SEAWEED_ADMIN_PASSWORD`: `weed admin` serves the panel with no
|
|
authentication when it is empty, and a panel that can mint bucket credentials is
|
|
the bucket (the baked-in image refuses to start the panel at all without one).
|
|
In the prod file it is bound to loopback like `SEAWEED_S3_BIND` — it is storage
|
|
plumbing, not one of the app's own panels — so a remote host needs
|
|
`SEAWEED_ADMIN_BIND=0.0.0.0` behind a reverse proxy. Neither file publishes
|
|
anything for master, volume and filer: the volume server serves file content by
|
|
id with no authentication of any kind, and the admin UI already shows what those
|
|
ports would.
|
|
|
|
Switching between any of the SeaweedFS files needs no migration: master, volume
|
|
and filer share one data mount (`/data` in the side containers, `/seaweed/data`
|
|
in the baked-in image), which is exactly the layout `weed server -dir` writes.
|
|
|
|
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}` on the **API Server port
|
|
(8080)** — not through nginx — authenticating with a per-charger control token
|
|
in an OCPP Basic-auth header. Because a plaintext `ws://` would expose that
|
|
token, `OCPP_REQUIRE_TLS` defaults to `true`.
|
|
|
|
This image serves plain HTTP, so charger control needs TLS terminated in front
|
|
of it, with `OCPP_PUBLIC_URL` set to the public `wss://` base. Only drop
|
|
`OCPP_REQUIRE_TLS` on a trusted network.
|
|
|
|
## Caveats
|
|
|
|
- PocketBase and the API Server run as the unprivileged `app` user; only nginx
|
|
stays root, because it binds port 80. A crash of `supervisord` still takes all
|
|
three services down together — that is the trade for the simplicity.
|
|
- Logs from all three processes are interleaved on the container's stdout/stderr
|
|
(`docker logs drivervault-aio`).
|
|
- `PB_VERSION` is pinned in the Dockerfile so two builds of the same source
|
|
agree. Setting it to an empty string restores the old behaviour of resolving
|
|
the latest release **at build time**, which also costs an unauthenticated
|
|
GitHub API call and is subject to that 60/hour per-IP rate limit.
|
|
- The container reports health once all three processes answer their probes, so
|
|
a wedged component shows up in `docker ps` rather than looking up.
|