Files
DriverVault/Docker-AIO/Dockerfile.seaweedfs.split
T
tajniak81andClaude Opus 5 28f5fda451 The split object store moves into the image it was kept out of
Dockerfile.seaweedfs.split bakes SeaweedFS master, volume, filer, S3
gateway and admin UI into the all-in-one as five more supervisord
programs, with the weed binary copied from the official image and pinned
by SEAWEED_VERSION. The readiness chain the compose split expressed with
depends_on and healthchecks becomes until-wget loops: master, volume,
filer, then the gateway, which seeds the bucket and PocketBase's identity
and refuses to serve if the grep gate fails. The volume server sits on
8081 because 8080 is the API Server; every role except the gateway and
the admin UI binds loopback.

docker-compose.prod.seaweedfs.split.yml goes from six services to one,
with build: and image: both set so the same file builds, pushes and
pulls, under a registry name of its own since this image expects three
volumes. The env example and README follow.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 14:43:49 +02:00

463 lines
21 KiB
Docker

# 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"]