# syntax=docker/dockerfile:1 # # All-in-one image WITH the object store inside: PocketBase + API Server + Web # App, plus SeaweedFS split into its roles — master, volume, filer, S3 gateway # and the admin UI — each its own supervisord program in the same container. # # This is Dockerfile with the six SeaweedFS containers of # docker-compose.prod.seaweedfs.split.yml folded in. The compose split kept # them outside the image so SeaweedFS could be upgraded without a rebuild; this # file trades that away for a single image and a single container. What the # split still buys in here: per-role restart under supervisord, per-role # metrics ports, and the admin UI, where S3 identities are minted and revoked. # What it costs: a new SeaweedFS means a rebuild (--build-arg SEAWEED_VERSION). # # The build context MUST be the project root so this file can reach both # "API Server/" and "Web App/". The root .dockerignore is an allow-list of the # paths copied below — add to it if you add a COPY here. Build it with: # # docker build -f "Docker-AIO/Dockerfile.seaweedfs.split" -t drivervault-aio-seaweedfs-split . # # Run it (everything starts together): # # docker run -d --name drivervault -p 80:80 -p 8070:8070 -p 8080:8080 \ # -p 127.0.0.1:23646:23646 \ # -e PB_ADMIN_EMAIL=admin@example.com \ # -e PB_ADMIN_PASSWORD=change-me \ # -e PB_S3_ACCESS_KEY=drivervault -e PB_S3_SECRET=change-me \ # -e WEED_ADMIN_PASSWORD=change-me \ # -v drivervault_pb:/pb/pb_data \ # -v drivervault_seaweed:/seaweed/data \ # -v drivervault_seaweed_admin:/seaweed/admin \ # drivervault-aio-seaweedfs-split # # Then: web app on http://host/, PocketBase admin on http://host:8070/_/, and # the SeaweedFS admin UI on http://127.0.0.1:23646/. On first boot the S3 # gateway's program creates the bucket and seeds PocketBase's identity, and the # API Server creates the collections, the super-admin, and points PocketBase's # file storage at the bucket. # # Port map inside the container (only 80, 8070, 8080, 8333 and 23646 are meant # to be published; the rest are bound to loopback): # # 80 nginx (Web App, /api/ and /ocpp/ proxied) # 8070 PocketBase # 8080 API Server ← which is why the volume server is NOT on its # 8081 SeaweedFS volume usual 8080 here # 8333 SeaweedFS S3 gateway (PocketBase's endpoint; publish on loopback) # 8888 SeaweedFS filer # 9333 SeaweedFS master # 23646 SeaweedFS admin UI (publish on loopback, behind a proxy if remote) # SeaweedFS release to bake in, pinned like PB_VERSION so a rebuild months from # now brings up the same one. Same tag the compose SeaweedFS files run as # SEAWEED_IMAGE. Override with --build-arg SEAWEED_VERSION=... to upgrade. ARG SEAWEED_VERSION="4.45" # --- Stage 1: build the Go API Server --------------------------------------- FROM golang:1.26-alpine3.24 AS api-build WORKDIR /src COPY ["API Server/go.mod", "./"] COPY ["API Server/go.su[m]", "./"] RUN go mod download # Only cmd/ + internal are needed; the panel is already built into # internal/api/dist and embedded via //go:embed. Entry point is cmd/server. COPY ["API Server/cmd", "./cmd"] COPY ["API Server/internal", "./internal"] RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -o /out/api-server ./cmd/server # --- Stage 2: build the Vue Web App ----------------------------------------- # The Vue source lives under "Web App/web/". FROM node:22-alpine3.24 AS web-build WORKDIR /app COPY ["Web App/web/package.json", "Web App/web/package-lock.json", "./"] RUN npm ci COPY ["Web App/web/index.html", "Web App/web/vite.config.js", "./"] COPY ["Web App/web/src", "./src"] COPY ["Web App/web/public", "./public"] # Empty -> bundle uses same-origin "/api", proxied to the API Server by nginx. ARG VITE_API_BASE # vite.config writes to ../server/dist by default; emit into ./dist here. RUN npm run build -- --outDir dist --emptyOutDir # --- Stage 3: SeaweedFS ----------------------------------------------------- # The one static binary from the official image. SEAWEED_VERSION is declared at # the top of the file: an ARG used in a FROM has to be global, and one declared # here would belong to the stage above and expand to nothing. FROM chrislusf/seaweedfs:${SEAWEED_VERSION} AS seaweed # --- Stage 4: runtime (all services) ---------------------------------------- FROM alpine:3.24 # Pinned so a rebuild months from now produces the same PocketBase. Override to # upgrade (--build-arg PB_VERSION=0.40.0); set it to empty to resolve the latest # release at build time, which needs an unauthenticated GitHub API call and is # therefore subject to that GitHub rate limit (60/hour per IP). ARG PB_VERSION="0.39.11" # Provided automatically by BuildKit (amd64 / arm64). ARG TARGETARCH="amd64" RUN apk add --no-cache ca-certificates tzdata unzip wget nginx supervisor \ && mkdir -p /run/nginx # Unprivileged account for PocketBase, the API Server and every SeaweedFS role. # Only nginx stays root, because it binds port 80; supervisord drops to this # user for everything else. RUN addgroup -S app && adduser -S -G app app # PocketBase from the official release (pinned via PB_VERSION, else latest). WORKDIR /pb RUN set -eux; \ ver="${PB_VERSION}"; \ if [ -z "$ver" ]; then \ ver="$(wget -qO- https://api.github.com/repos/pocketbase/pocketbase/releases/latest \ | grep -o '"tag_name": *"v[^"]*"' | head -1 | sed -E 's/.*"v([^"]+)".*/\1/')"; \ fi; \ echo "Installing PocketBase v${ver} (${TARGETARCH})"; \ wget -q -O /tmp/pb.zip \ "https://github.com/pocketbase/pocketbase/releases/download/v${ver}/pocketbase_${ver}_linux_${TARGETARCH}.zip"; \ unzip /tmp/pb.zip -d /pb; \ rm /tmp/pb.zip # API Server binary, built Web App static assets, and the weed binary. COPY --from=api-build /out/api-server /usr/local/bin/api-server COPY --from=web-build /app/dist /usr/share/nginx/html COPY --from=seaweed /usr/bin/weed /usr/local/bin/weed # WebSocket handshakes need Connection/Upgrade forwarded, and the map deriving # them must sit in the http context, not inside a server block. It goes in # http.d/ (which Alpine nginx includes from http{}; it does not read conf.d/), # and the 00- prefix keeps it ahead of default.conf in the include order. RUN cat > /etc/nginx/http.d/00-upgrade.conf <<'NGINXMAP' map $http_upgrade $connection_upgrade { default upgrade; '' close; } NGINXMAP # nginx: serve the SPA and proxy /api/ + /ocpp/ to the API Server on localhost. RUN cat > /etc/nginx/http.d/default.conf <<'NGINX' server { listen 80; server_name _; root /usr/share/nginx/html; index index.html; gzip on; gzip_types text/plain text/css application/javascript application/json image/svg+xml; gzip_min_length 1024; # A real liveness route, so the SPA fallback below cannot answer a health # probe with index.html and make a broken container look healthy. location = /healthz { access_log off; add_header Content-Type text/plain; return 200 "ok"; } location /api/ { proxy_pass http://127.0.0.1:8080; proxy_http_version 1.1; # Forward WebSocket upgrades instead of silently stripping them. proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection $connection_upgrade; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; } # The chargers' door, not the browser's. The API Server tells a charger to # dial the host it was itself asked on — this one — so without this location # the SPA fallback would answer the WebSocket handshake with index.html. location /ocpp/ { proxy_pass http://127.0.0.1:8080; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection $connection_upgrade; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; # A charging session is idle between heartbeats; the default 60s would # close it under the charger. proxy_read_timeout 1h; proxy_send_timeout 1h; } location /assets/ { expires 1y; add_header Cache-Control "public, immutable"; try_files $uri =404; } location / { try_files $uri $uri/ /index.html; } } NGINX # Bucket and identity seed, run by the S3 gateway's program before it serves. # Two jobs, both idempotent, so every boot re-applies the values from the # environment and changes nothing else — which is also how a rotated # PB_S3_SECRET reaches the gateway: # # 1. create the bucket — PocketBase never issues a CreateBucket of its own; # 2. write PocketBase's S3 identity into the filer's IAM store. # # (2) is why the gateway below runs with neither an -config file nor # AWS_ACCESS_KEY_ID: the env vars are the lowest-priority credential source in # SeaweedFS, read only while the filer's store is empty, so the first identity # added in the admin UI would silently displace them and lock PocketBase out. # Seeding the store the admin UI itself writes leaves one source of truth, and # PocketBase's key shows under Object Store → Users like any other. # # The closing grep is the gate: an empty IAM store means the gateway would come # up in its allow-anyone default, so this fails loudly instead and the gateway # never starts (supervisord retries it, and the healthcheck stays red). RUN cat > /usr/local/bin/seaweedfs-seed <<'SEED' #!/bin/sh set -e : "${PB_S3_ACCESS_KEY:?seaweedfs-seed: PB_S3_ACCESS_KEY is required}" : "${PB_S3_SECRET:?seaweedfs-seed: PB_S3_SECRET is required}" bucket="${PB_S3_BUCKET:-drivervault}" shell() { weed shell -master=127.0.0.1:9333 -filer=127.0.0.1:8888; } printf '%s\n' \ "s3.bucket.create -name $bucket" \ "s3.configure -user drivervault -access_key $PB_S3_ACCESS_KEY -secret_key $PB_S3_SECRET -actions Admin -apply" \ | shell if ! echo "s3.configure" | shell | grep -q "$PB_S3_ACCESS_KEY"; then echo "seaweedfs-seed: PocketBase's identity is not in the filer's IAM store; refusing to start the gateway" >&2 exit 1 fi SEED RUN chmod +x /usr/local/bin/seaweedfs-seed # supervisord runs the eight processes and keeps them alive. Priorities only # order the launches; readiness is the `until wget` loop in front of each # program that needs another one up, the same chain the compose split spells # out with depends_on + healthchecks: master → volume → filer → seed + S3 → # PocketBase → API Server. # # Every SeaweedFS role advertises and binds 127.0.0.1 (-ip / -ip.bind): they only # ever talk to each other in here, and the volume server in particular serves # file content by id with no authentication at all. The S3 gateway and the admin # UI bind everywhere so they can be published — on loopback, by the compose # file's default. RUN cat > /etc/supervisord.conf <<'SUPERVISOR' [supervisord] nodaemon=true user=root pidfile=/run/supervisord.pid logfile=/dev/null logfile_maxbytes=0 ; SeaweedFS master: volume/topology metadata and file ids. [program:seaweedfs-master] user=app command=/usr/local/bin/weed master -ip=127.0.0.1 -ip.bind=127.0.0.1 -port=9333 -mdir=/seaweed/data -volumeSizeLimitMB=1024 -metricsPort=9324 priority=1 autostart=true autorestart=true stdout_logfile=/dev/stdout stdout_logfile_maxbytes=0 stderr_logfile=/dev/stderr stderr_logfile_maxbytes=0 ; SeaweedFS volume server: where the bytes land. On 8081 because 8080 is the ; API Server in this container. -max=0 sizes itself from free disk rather than ; the default cap of 8 volumes. [program:seaweedfs-volume] user=app command=/bin/sh -c 'until wget -qO- http://127.0.0.1:9333/cluster/status >/dev/null 2>&1; do echo "waiting for seaweedfs master..."; sleep 1; done; exec /usr/local/bin/weed volume -master=127.0.0.1:9333 -ip=127.0.0.1 -ip.bind=127.0.0.1 -port=8081 -dir=/seaweed/data -max=0 -metricsPort=9325' priority=2 autostart=true autorestart=true stdout_logfile=/dev/stdout stdout_logfile_maxbytes=0 stderr_logfile=/dev/stderr stderr_logfile_maxbytes=0 ; SeaweedFS filer: the directory tree over the flat volume store — buckets, ; object keys — and the IAM store the S3 identities live in. -defaultStoreDir ; keeps its embedded leveldb on the data volume so they survive a recreate. [program:seaweedfs-filer] user=app command=/bin/sh -c 'until wget -qO- http://127.0.0.1:8081/healthz >/dev/null 2>&1; do echo "waiting for seaweedfs volume..."; sleep 1; done; exec /usr/local/bin/weed filer -master=127.0.0.1:9333 -ip=127.0.0.1 -ip.bind=127.0.0.1 -port=8888 -defaultStoreDir=/seaweed/data -metricsPort=9326' priority=3 autostart=true autorestart=true stdout_logfile=/dev/stdout stdout_logfile_maxbytes=0 stderr_logfile=/dev/stderr stderr_logfile_maxbytes=0 ; SeaweedFS S3 gateway: PocketBase's endpoint. Seeds the bucket and identity ; first (see /usr/local/bin/seaweedfs-seed) and never serves if that fails. No ; -config file: with only -filer given, credentials come from the filer's IAM ; store, which is what lets the admin UI add and revoke identities without a ; restart. [program:seaweedfs-s3] user=app command=/bin/sh -c 'until wget -qO- http://127.0.0.1:8888/healthz >/dev/null 2>&1; do echo "waiting for seaweedfs filer..."; sleep 1; done; /usr/local/bin/seaweedfs-seed && exec /usr/local/bin/weed s3 -filer=127.0.0.1:8888 -ip.bind=0.0.0.0 -port=8333 -metricsPort=9327' priority=4 autostart=true autorestart=true stdout_logfile=/dev/stdout stdout_logfile_maxbytes=0 stderr_logfile=/dev/stderr stderr_logfile_maxbytes=0 ; SeaweedFS admin UI: cluster view, buckets, maintenance, and Object Store → ; Users. It can mint credentials for the bucket, and weed leaves auth off ; entirely when WEED_ADMIN_PASSWORD is empty — so an empty password means no ; panel at all rather than an open one. -dataDir persists the session key and ; the maintenance-task settings. [program:seaweedfs-admin] user=app command=/bin/sh -c 'if [ -z "$WEED_ADMIN_PASSWORD" ]; then echo "seaweedfs-admin: WEED_ADMIN_PASSWORD is empty; refusing to serve an unauthenticated panel" >&2; exit 1; fi; until wget -qO- http://127.0.0.1:9333/cluster/status >/dev/null 2>&1; do echo "waiting for seaweedfs master..."; sleep 1; done; exec /usr/local/bin/weed admin -port=23646 -master=127.0.0.1:9333 -dataDir=/seaweed/admin -metricsPort=9328' priority=5 autostart=true autorestart=true stdout_logfile=/dev/stdout stdout_logfile_maxbytes=0 stderr_logfile=/dev/stderr stderr_logfile_maxbytes=0 ; PocketBase: wait for the S3 gateway — it is the process that reads and writes ; the objects, so the bucket has to be serving before it does, exactly as the ; compose split gates the whole container on the gateway's health. Then upsert ; the superuser (idempotent) and serve. Runs as the unprivileged app user, which ; owns /pb and the pb_data volume. [program:pocketbase] directory=/pb user=app command=/bin/sh -c 'until wget -qO- http://127.0.0.1:8333/healthz >/dev/null 2>&1; do echo "waiting for seaweedfs s3..."; sleep 1; done; /pb/pocketbase superuser upsert "$PB_ADMIN_EMAIL" "$PB_ADMIN_PASSWORD" 2>/dev/null || true; exec /pb/pocketbase serve --http=0.0.0.0:8070' priority=10 autostart=true autorestart=true stdout_logfile=/dev/stdout stdout_logfile_maxbytes=0 stderr_logfile=/dev/stderr stderr_logfile_maxbytes=0 ; API Server: wait for PocketBase to be healthy, then start. Its bootstrap ; points PocketBase's file storage at the bucket and asks it to prove the ; gateway is reachable. It keeps no state on disk — plugin settings, like ; everything else it owns, live in PocketBase — so its working directory is ; just a place to run from. [program:api-server] directory=/app user=app command=/bin/sh -c 'until wget -qO- http://127.0.0.1:8070/api/health >/dev/null 2>&1; do echo "waiting for pocketbase..."; sleep 1; done; exec /usr/local/bin/api-server' priority=20 autostart=true autorestart=true stdout_logfile=/dev/stdout stdout_logfile_maxbytes=0 stderr_logfile=/dev/stderr stderr_logfile_maxbytes=0 ; nginx stays root so it can bind :80; its own workers drop to the nginx user. [program:nginx] command=/usr/sbin/nginx -g 'daemon off;' priority=30 autostart=true autorestart=true stdout_logfile=/dev/stdout stdout_logfile_maxbytes=0 stderr_logfile=/dev/stderr stderr_logfile_maxbytes=0 SUPERVISOR # The entrypoint stays root only long enough to make the data volumes writable # by the app user, then hands off to supervisord. The chown matters for a host # bind mount, which arrives owned by root rather than inheriting the image's # owner. RUN cat > /entrypoint.sh <<'ENTRY' #!/bin/sh set -e for dir in /pb/pb_data /seaweed/data /seaweed/admin; do mkdir -p "$dir" if [ "$(stat -c %U "$dir" 2>/dev/null)" != "app" ]; then echo "entrypoint: taking ownership of $dir" chown -R app:app "$dir" fi done exec supervisord -c /etc/supervisord.conf ENTRY RUN chmod +x /entrypoint.sh # API Server config: everything is local to this container. WEBAPP_URL is what # the panel status page probes; nginx serves the Web App on :80 in here, so the # stock default of localhost:8090 would always report the Web App as down. # # File storage is on by construction: the gateway is in this image, at a fixed # loopback address, path style because a self-hosted gateway has no per-bucket # DNS. The region is a formality SeaweedFS ignores and PocketBase insists on. # supervisord passes these through to the API Server, whose bootstrap writes # them into PocketBase's settings on every boot. Only record files move — scans, # receipts, invoices, part photos; the database and PocketBase's own backups # stay on /pb/pb_data. ENV API_ADDR=:8080 \ POCKETBASE_URL=http://127.0.0.1:8070 \ CORS_ALLOW_ORIGINS=http://localhost:8090 \ AUTH_USERS_COLLECTION=users \ WEBAPP_URL=http://127.0.0.1:80 \ PB_S3_ENABLED=true \ PB_S3_ENDPOINT=http://127.0.0.1:8333 \ PB_S3_BUCKET=drivervault \ PB_S3_REGION=us-east-1 \ PB_S3_FORCE_PATH_STYLE=true \ WEED_ADMIN_USER=admin # Required at runtime (no safe defaults): PB_ADMIN_EMAIL, PB_ADMIN_PASSWORD; # PB_S3_ACCESS_KEY, PB_S3_SECRET (the identity seeded into SeaweedFS AND what # PocketBase authenticates with — the gateway refuses to start without them); # WEED_ADMIN_PASSWORD (the admin UI refuses to start without it). # Optional: DRIVERVAULT_SUPERADMIN_EMAIL / DRIVERVAULT_SUPERADMIN_PASSWORD create # the first app super-admin on boot; WEED_ADMIN_READONLY_USER / _PASSWORD add a # view-only login to the admin UI. PB_BOOTSTRAP=false skips schema setup — # leave it on: a release can add collections or fields the server needs, and a # stack that skips the bootstrap never gets them. (app_settings, which holds the # plugin settings, is created on demand; nothing else is.) # For Anker Solix charger control, OCPP_REQUIRE_TLS (default true) rejects # chargers that did not arrive over TLS — this image serves plain HTTP, so put a # TLS-terminating proxy in front and set OCPP_PUBLIC_URL to the public wss:// # base, or set OCPP_REQUIRE_TLS=false on a trusted network. # Pass them with `docker run -e ...`. # Three volumes. /pb/pb_data: the database, the uploads made before S3 was on, # PocketBase's own backups, and the server settings — the API Server keeps no # state on disk. /seaweed/data: master, volume and filer share it, in exactly # the layout `weed server -dir` writes (master raft state, volume .dat/.idx, the # filer's filerldb2/ — no filename overlap), so a SEAWEED_DATA volume from # either compose SeaweedFS file mounts here unchanged, and vice versa. # /seaweed/admin: the admin UI's session key and maintenance-task state, small # and no part of the object store. All pre-created and owned by app so a fresh # named volume inherits that ownership. RUN mkdir -p /pb/pb_data /seaweed/data /seaweed/admin /app \ && chown -R app:app /pb /seaweed /app VOLUME ["/pb/pb_data", "/seaweed/data", "/seaweed/admin"] # 80 = Web App, 8070 = PocketBase admin, 8080 = API Server + embedded API panel # (also the /ocpp/{serial} endpoint chargers dial into), 8333 = S3 gateway (for # aws-cli and the like; loopback is enough), 23646 = SeaweedFS admin UI. EXPOSE 80 8070 8080 8333 23646 # Every process must answer, so a wedged component shows up in `docker ps` # instead of a container that looks up while part of it is dead. start-period # covers the SeaweedFS chain plus the first-boot schema bootstrap on a cold # database. HEALTHCHECK --interval=30s --timeout=10s --start-period=90s --retries=3 \ CMD wget -qO- http://127.0.0.1:9333/cluster/status >/dev/null 2>&1 \ && wget -qO- http://127.0.0.1:8081/healthz >/dev/null 2>&1 \ && wget -qO- http://127.0.0.1:8888/healthz >/dev/null 2>&1 \ && wget -qO- http://127.0.0.1:8333/healthz >/dev/null 2>&1 \ && wget -qO- http://127.0.0.1:23646/health >/dev/null 2>&1 \ && wget -qO- http://127.0.0.1:8070/api/health >/dev/null 2>&1 \ && wget -qO- http://127.0.0.1:8080/healthz >/dev/null 2>&1 \ && wget -qO- http://127.0.0.1:80/healthz >/dev/null 2>&1 \ || exit 1 ENTRYPOINT ["/entrypoint.sh"]