# syntax=docker/dockerfile:1 # # All-in-one image WITH the object store inside: PocketBase + API Server + Web # App, plus SeaweedFS as one `weed server -s3` process — master, volume, filer # and S3 gateway as goroutines — under supervisord in the same container. # # This is Dockerfile with the two SeaweedFS containers of # docker-compose.prod.seaweedfs.yml folded in. That compose file 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 it costs: a # new SeaweedFS means a rebuild (--build-arg SEAWEED_VERSION). For per-role # restart, metrics and the admin UI, see Dockerfile.seaweedfs.split. # # 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" -t drivervault-aio-seaweedfs . # # Run it (everything starts together): # # docker run -d --name drivervault -p 80:80 -p 8070:8070 -p 8080:8080 \ # -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 \ # -v drivervault_pb:/pb/pb_data \ # -v drivervault_seaweed:/seaweed/data \ # drivervault-aio-seaweedfs # # Then: web app on http://host/ and PocketBase admin on http://host:8070/_/. # On first boot PocketBase's program creates the bucket, 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 and 8333 are meant to be # published): # # 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 # 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 SeaweedFS. 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 # supervisord runs the four 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 file spells out # with depends_on + healthchecks: SeaweedFS → bucket → PocketBase → API Server. RUN cat > /etc/supervisord.conf <<'SUPERVISOR' [supervisord] nodaemon=true user=root pidfile=/run/supervisord.pid logfile=/dev/null logfile_maxbytes=0 ; SeaweedFS: one process, four roles — master, volume, filer and the S3 ; gateway. -dir is the only state it keeps. -ip=127.0.0.1 is the address the ; roles advertise to each other, which is where they are; -ip.bind stays open ; so the gateway can be published (weed server has one bind flag for all four, ; and the volume server, on 8081 because 8080 is the API Server, serves file ; content by id with no authentication — so never publish 8081). ; ; SeaweedFS falls back to AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY when started ; without an -s3.config file, and configuring one identity is what takes the ; gateway out of its default allow-anyone mode. They are the PB_S3_* pair ; PocketBase authenticates with, mapped here so there is one pair to set — and ; an empty pair means no gateway rather than an open one. [program:seaweedfs] user=app command=/bin/sh -c 'if [ -z "$PB_S3_ACCESS_KEY" ] || [ -z "$PB_S3_SECRET" ]; then echo "seaweedfs: PB_S3_ACCESS_KEY and PB_S3_SECRET are required; refusing to start an unauthenticated gateway" >&2; exit 1; fi; export AWS_ACCESS_KEY_ID="$PB_S3_ACCESS_KEY" AWS_SECRET_ACCESS_KEY="$PB_S3_SECRET"; exec /usr/local/bin/weed server -dir=/seaweed/data -ip=127.0.0.1 -ip.bind=0.0.0.0 -volume.port=8081 -s3 -master.volumeSizeLimitMB=1024' priority=1 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 file gates the whole container on the gateway's health. Then create ; the bucket: PocketBase never issues a CreateBucket of its own and SeaweedFS ; will not conjure one on first upload. Creating a bucket that already exists is ; a no-op, and `|| true` keeps a restart from being blocked by the shell's exit ; status — a gateway that is genuinely broken is reported by the API Server's ; own S3 check at boot, with the reason. 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..."; sleep 1; done; echo "s3.bucket.create -name $PB_S3_BUCKET" | /usr/local/bin/weed shell -master=127.0.0.1:9333 || true; /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; 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 # Required at runtime (no safe defaults): PB_ADMIN_EMAIL, PB_ADMIN_PASSWORD; # PB_S3_ACCESS_KEY, PB_S3_SECRET (the gateway's single identity AND what # PocketBase authenticates with — the gateway refuses to start without them). # Optional: DRIVERVAULT_SUPERADMIN_EMAIL / DRIVERVAULT_SUPERADMIN_PASSWORD create # the first app super-admin on boot. 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 ...`. # Two 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: everything SeaweedFS keeps, in the layout # `weed server -dir` writes — the same one the split image's master, volume and # filer share, so a SEAWEED_DATA volume from any of the SeaweedFS compose files # mounts here unchanged, and vice versa. Both pre-created and owned by app so a # fresh named volume inherits that ownership. RUN mkdir -p /pb/pb_data /seaweed/data /app \ && chown -R app:app /pb /seaweed /app VOLUME ["/pb/pb_data", "/seaweed/data"] # 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). EXPOSE 80 8070 8080 8333 # 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 SeaweedFS coming up 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:8333/healthz >/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"]