From f08849e50cfd039cb34844c6fd4ce67c9d4295f0 Mon Sep 17 00:00:00 2001 From: tajniak81 <13187254+tajniak81@users.noreply.github.com> Date: Thu, 20 Aug 2026 17:07:52 +0200 Subject: [PATCH] Docker: a build context that isn't 3.6GB, and health you can see MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The all-in-one image builds from the project root, and Docker only reads .dockerignore from the context root — so the ones under "API Server" and "Web App" never applied to it and every AIO build shipped the whole tree, "Phone App/build" included. A root .dockerignore allow-lists the paths that build actually copies. The dev split stack passed neither PB_BOOTSTRAP nor the SUPERADMIN vars, so it created the schema and then no user to log in with. It passes them now, and .env.example says so. WEBAPP_URL was never set anywhere, leaving the panel status page probing localhost:8090 — itself — and always reporting the Web App as down. Each compose file now points it at wherever the Web App really is, and the BFF grew a real /healthz instead of letting the SPA fallback answer probes with index.html and look healthy no matter what. In the AIO, PocketBase and the API Server drop to an unprivileged user; only nginx stays root to bind :80. The entrypoint takes ownership of the two volumes first, so data written by the old root-only image stays writable. All three images carry a HEALTHCHECK, every compose file declares one too (so depends_on still gates against an older pulled image), and web-app waits for the API Server to be serving rather than merely started. Also: pinned alpine/golang/node and PocketBase 0.39.11, so a rebuild months from now produces the same image; nginx forwards WebSocket upgrades instead of stripping them, with the map in http.d where Alpine actually reads it; and a .gitattributes keeps entrypoint.sh on LF, because a CRLF shebang from a Windows clone fails at container start with "no such file or directory". Co-Authored-By: Claude Opus 5 --- .dockerignore | 26 +++++++++ .gitattributes | 15 +++++ API Server/.dockerignore | 10 +++- API Server/Dockerfile | 11 +++- API Server/docker-compose.yml | 15 +++++ Docker-AIO/.env.example | 6 +- Docker-AIO/Dockerfile | 92 +++++++++++++++++++++++++----- Docker-AIO/README.md | 19 +++--- Docker-AIO/docker-compose.prod.yml | 11 ++++ Docker-AIO/docker-compose.yml | 19 +++++- Docker/.env.example | 11 ++++ Docker/docker-compose.prod.yml | 23 +++++++- Docker/docker-compose.yml | 30 +++++++++- Docker/pocketbase/Dockerfile | 11 ++-- Web App/Dockerfile | 13 ++++- Web App/server/main.go | 10 ++++ 16 files changed, 284 insertions(+), 38 deletions(-) create mode 100644 .dockerignore create mode 100644 .gitattributes diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..f092a7c --- /dev/null +++ b/.dockerignore @@ -0,0 +1,26 @@ +# Build context for Docker-AIO/Dockerfile, which builds from the PROJECT ROOT so +# it can reach both "API Server/" and "Web App/". Docker reads .dockerignore only +# from the context root, so the per-directory ignore files under "API Server/" +# and "Web App/" do NOT apply to that build — this file is what keeps it small. +# Without it the daemon receives ~3.6 GB (Phone App/build alone is 3 GB). +# +# It is an allow-list: everything is excluded, then the exact paths the AIO +# Dockerfile copies are added back. If you add a COPY to that Dockerfile, add +# its path here too or the build fails with "no source files were specified". +* + +# --- Stage 1: the Go API Server --------------------------------------------- +!API Server/go.mod +!API Server/go.sum +!API Server/cmd +!API Server/internal + +# --- Stage 2: the Vue Web App ------------------------------------------------ +!Web App/web + +# ...but never its dependencies; npm ci reinstalls them inside the image. +Web App/web/node_modules + +# Secrets and local artifacts, re-excluded in case a rule above lets them in. +**/.env +**/*.log diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..5032327 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,15 @@ +# Default: let Git normalise text in the repository and check it out natively. +* text=auto + +# Anything that ends up inside a Linux container must keep LF endings. This repo +# is developed on Windows with core.autocrlf=true, so without these rules a +# fresh clone would give entrypoint.sh a CRLF shebang (#!/bin/sh\r), which the +# kernel rejects with a confusing "no such file or directory" at container start. +# Dockerfiles matter too: their heredocs write the scripts and configs above. +*.sh text eol=lf +Dockerfile text eol=lf +*.dockerfile text eol=lf +.dockerignore text eol=lf +docker-compose*.yml text eol=lf +*.env.example text eol=lf +.env.example text eol=lf diff --git a/API Server/.dockerignore b/API Server/.dockerignore index 11fe758..595bee5 100644 --- a/API Server/.dockerignore +++ b/API Server/.dockerignore @@ -6,11 +6,17 @@ tmp/ # Panel source & tooling — the built output in internal/api/dist is committed # and embedded at compile time, so the source tree is not needed in the image. -panel/node_modules/ -panel/dist/ +# Excluding all of panel/ (not just its node_modules) also keeps edits to the +# panel source from invalidating the `COPY . .` layer on every rebuild. +panel/ + +# Runtime state written by a local (non-container) run. In the image this file +# lives on the /data volume, so a copy from the host would only bust the cache. +plugins.json # VCS / editor noise .git/ .gitignore +.claude/ .vscode/ .idea/ diff --git a/API Server/Dockerfile b/API Server/Dockerfile index c9a5b25..fd41196 100644 --- a/API Server/Dockerfile +++ b/API Server/Dockerfile @@ -3,7 +3,7 @@ # --- Build stage ------------------------------------------------------------- # Compile a static Go binary. The Vue panel is pre-built into internal/api/dist # and embedded via //go:embed, so no Node toolchain is needed here. -FROM golang:1.26-alpine AS build +FROM golang:1.26-alpine3.24 AS build WORKDIR /src @@ -20,7 +20,7 @@ COPY . . RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -o /out/api-server ./cmd/server # --- Runtime stage ----------------------------------------------------------- -FROM alpine:latest +FROM alpine:3.24 # HTTPS calls to PocketBase need CA certificates; tzdata for correct timestamps. RUN apk add --no-cache ca-certificates tzdata @@ -47,4 +47,11 @@ ENV API_ADDR=:8080 \ EXPOSE 8080 USER app + +# Liveness only: /healthz answers 200 as soon as the process is serving, and +# does not depend on PocketBase, so a database outage does not mark the +# container unhealthy. Lets compose gate dependants on condition: service_healthy. +HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \ + CMD wget -qO- http://127.0.0.1:8080/healthz >/dev/null 2>&1 || exit 1 + ENTRYPOINT ["/usr/local/bin/api-server"] diff --git a/API Server/docker-compose.yml b/API Server/docker-compose.yml index 3f028c4..bac9218 100644 --- a/API Server/docker-compose.yml +++ b/API Server/docker-compose.yml @@ -17,6 +17,10 @@ services: # superadmin can configure it from the panel; management endpoints 503 until then. POCKETBASE_ADMIN_EMAIL: "${PB_ADMIN_EMAIL}" POCKETBASE_ADMIN_PASSWORD: "${PB_ADMIN_PASSWORD}" + # Probed by the panel status page. Point it at wherever the Web App runs + # as seen from THIS container — the default (localhost:8090) is this + # container itself, so it must be set for the status page to be accurate. + WEBAPP_URL: "${WEBAPP_URL:-http://host.docker.internal:8090}" # Browser origins allowed by CORS (native apps are exempt). Point this at # the Web App origin; add http://localhost:5173 when running Vite in dev. CORS_ALLOW_ORIGINS: "${CORS_ALLOW_ORIGINS:-http://localhost:8090}" @@ -27,9 +31,20 @@ services: # base derived from request headers (set it when behind a reverse proxy). OCPP_REQUIRE_TLS: "${OCPP_REQUIRE_TLS:-true}" OCPP_PUBLIC_URL: "${OCPP_PUBLIC_URL:-}" + extra_hosts: + # Makes host.docker.internal resolve on Linux too (Docker Desktop provides + # it already), so the WEBAPP_URL default above can reach a Web App running + # on the host rather than in this compose file. + - "host.docker.internal:host-gateway" volumes: # Holds plugins.json and the .env the panel writes back — see Dockerfile. - api_data:/data + healthcheck: + test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:8080/healthz || exit 1"] + interval: 10s + timeout: 3s + retries: 12 + start_period: 20s volumes: api_data: diff --git a/Docker-AIO/.env.example b/Docker-AIO/.env.example index ab06861..b12e418 100644 --- a/Docker-AIO/.env.example +++ b/Docker-AIO/.env.example @@ -37,5 +37,7 @@ API_PORT=8080 # --- Build args (optional) --------------------------------------------------- # Leave empty so the browser uses same-origin /api (proxied by nginx). VITE_API_BASE= -# Pin a PocketBase version, or leave empty to fetch the latest at build time. -PB_VERSION= +# PocketBase version. The Dockerfile already pins one; set this only to build a +# different version. Leaving it commented out keeps the pin (an empty value here +# is passed through as-is and would resolve the latest release at build time). +#PB_VERSION=0.39.11 diff --git a/Docker-AIO/Dockerfile b/Docker-AIO/Dockerfile index 9a32a6c..301bad8 100644 --- a/Docker-AIO/Dockerfile +++ b/Docker-AIO/Dockerfile @@ -3,7 +3,8 @@ # All-in-one image: PocketBase + API Server + Web App in a single container. # # The build context MUST be the project root so this file can reach both -# "API Server/" and "Web App/". Build it with: +# "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" -t drivervault-aio . # @@ -22,7 +23,7 @@ # On first boot the API Server creates the collections and the super-admin. # --- Stage 1: build the Go API Server --------------------------------------- -FROM golang:1.26-alpine AS api-build +FROM golang:1.26-alpine3.24 AS api-build WORKDIR /src COPY ["API Server/go.mod", "./"] COPY ["API Server/go.su[m]", "./"] @@ -35,7 +36,7 @@ RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -o /out/api-ser # --- Stage 2: build the Vue Web App ----------------------------------------- # The Vue source lives under "Web App/web/". -FROM node:22-alpine AS web-build +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 @@ -48,14 +49,23 @@ ARG VITE_API_BASE RUN npm run build -- --outDir dist --emptyOutDir # --- Stage 3: runtime (all services) ---------------------------------------- -FROM alpine:latest +FROM alpine:3.24 -ARG PB_VERSION="" +# 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 and the API Server. Only nginx stays root, +# because it binds port 80; supervisord drops to this user for the other two. +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; \ @@ -74,6 +84,17 @@ RUN set -eux; \ COPY --from=api-build /out/api-server /usr/local/bin/api-server COPY --from=web-build /app/dist /usr/share/nginx/html +# 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/ to the API Server on localhost. RUN cat > /etc/nginx/http.d/default.conf <<'NGINX' server { @@ -86,9 +107,20 @@ server { 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; @@ -116,9 +148,11 @@ pidfile=/run/supervisord.pid logfile=/dev/null logfile_maxbytes=0 -; PocketBase: upsert the superuser (idempotent) then serve. +; PocketBase: upsert the superuser (idempotent) then 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 '/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 @@ -129,10 +163,11 @@ stderr_logfile=/dev/stderr stderr_logfile_maxbytes=0 ; API Server: wait for PocketBase to be healthy, then start. It runs from /data -; because it writes plugins.json and the panel's .env relative to its working +; because it writes plugins.json and the panel .env relative to its working ; directory, and /data is the volume that keeps them across container recreates. [program:api-server] directory=/data +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 @@ -142,6 +177,7 @@ 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 @@ -153,12 +189,32 @@ stderr_logfile=/dev/stderr stderr_logfile_maxbytes=0 SUPERVISOR -# API Server config: everything is local to this container. +# The entrypoint stays root only long enough to make the two volumes writable by +# the app user, then hands off to supervisord. The chown matters for volumes +# created by an earlier build of this image, when both services ran as root. +RUN cat > /entrypoint.sh <<'ENTRY' +#!/bin/sh +set -e +for dir in /pb/pb_data /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. ENV API_ADDR=:8080 \ POCKETBASE_URL=http://127.0.0.1:8070 \ CORS_ALLOW_ORIGINS=http://localhost:8090 \ AUTH_USERS_COLLECTION=users \ - PLUGINS_FILE=/data/plugins.json + PLUGINS_FILE=/data/plugins.json \ + WEBAPP_URL=http://127.0.0.1:80 # Required at runtime (no safe defaults): PB_ADMIN_EMAIL, PB_ADMIN_PASSWORD. # Optional: DRIVERVAULT_SUPERADMIN_EMAIL / DRIVERVAULT_SUPERADMIN_PASSWORD create @@ -169,13 +225,23 @@ ENV API_ADDR=:8080 \ # base, or set OCPP_REQUIRE_TLS=false on a trusted network. # Pass them with `docker run -e ...`. -RUN mkdir -p /data -# pb_data holds the database; /data holds the API Server's plugins.json and the -# .env the panel rewrites when a superadmin retargets PocketBase. +# pb_data holds the database; /data holds the API Server plugins.json and the +# .env the panel rewrites when a superadmin retargets PocketBase. Both are +# pre-created and owned by app so a fresh named volume inherits that ownership. +RUN mkdir -p /pb/pb_data /data && chown -R app:app /pb /data VOLUME /pb/pb_data VOLUME /data # 80 = Web App, 8070 = PocketBase admin, 8080 = API Server + embedded API panel # (also the /ocpp/{serial} endpoint chargers dial into). EXPOSE 80 8070 8080 -CMD ["supervisord", "-c", "/etc/supervisord.conf"] +# All three processes must answer, so a wedged component shows up in `docker ps` +# instead of a container that looks up while half of it is dead. start-period +# covers the first-boot schema bootstrap on a cold database. +HEALTHCHECK --interval=30s --timeout=5s --start-period=60s --retries=3 \ + CMD 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"] diff --git a/Docker-AIO/README.md b/Docker-AIO/README.md index 0428d45..7353a37 100644 --- a/Docker-AIO/README.md +++ b/Docker-AIO/README.md @@ -51,8 +51,9 @@ 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` (pin PocketBase, or leave empty to fetch the latest release at -build time). +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 @@ -87,10 +88,14 @@ of it, with `OCPP_PUBLIC_URL` set to the public `wss://` base. Only drop ## Caveats -- Everything runs as **root** in one container, and a crash of `supervisord` - takes all three services down together. That is the trade for the simplicity. +- 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` empty means the image pulls whatever PocketBase release is latest - **at build time**, so two builds of the same source can differ. Pin it for - reproducibility. +- `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. diff --git a/Docker-AIO/docker-compose.prod.yml b/Docker-AIO/docker-compose.prod.yml index bf90f11..4e6d6ce 100644 --- a/Docker-AIO/docker-compose.prod.yml +++ b/Docker-AIO/docker-compose.prod.yml @@ -23,6 +23,9 @@ services: PB_ADMIN_PASSWORD: "${PB_ADMIN_PASSWORD:?set PB_ADMIN_PASSWORD in .env}" # Match CORS to the web origin (only used if a browser calls the API directly). CORS_ALLOW_ORIGINS: "${CORS_ALLOW_ORIGINS:-http://localhost:8090}" + # Probed by the panel status page. nginx serves the Web App on port 80 + # inside this container, so the default (localhost:8090) would never answer. + WEBAPP_URL: "http://127.0.0.1:80" # Schema + super-admin bootstrap on boot (idempotent). Set PB_BOOTSTRAP=false # to skip once the database is established. PB_BOOTSTRAP: "${PB_BOOTSTRAP:-true}" @@ -45,6 +48,14 @@ services: - "${PB_DATA:-pb_data}:/pb/pb_data" # plugins.json + the .env the panel writes back. - "${API_DATA:-api_data}:/data" + healthcheck: + # All three processes must answer. Declared here as well as in the image so + # the check is visible, and works against an older pulled image. + test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:8070/api/health >/dev/null && wget -qO- http://127.0.0.1:8080/healthz >/dev/null && wget -qO- http://127.0.0.1:80/healthz >/dev/null || exit 1"] + interval: 30s + timeout: 5s + retries: 3 + start_period: 60s volumes: pb_data: diff --git a/Docker-AIO/docker-compose.yml b/Docker-AIO/docker-compose.yml index 35f59db..d74f7cb 100644 --- a/Docker-AIO/docker-compose.yml +++ b/Docker-AIO/docker-compose.yml @@ -12,9 +12,11 @@ services: dockerfile: Docker-AIO/Dockerfile args: # Empty -> bundle uses same-origin "/api", proxied internally by nginx. - VITE_API_BASE: "${VITE_API_BASE:-}" - # Optional: pin PocketBase; empty fetches the latest release at build. - PB_VERSION: "${PB_VERSION:-}" + - VITE_API_BASE=${VITE_API_BASE:-} + # Bare name = pass through only when set in the environment, so an unset + # PB_VERSION leaves the Dockerfile pin in place instead of overriding it + # with an empty string (which would resolve "latest" at build time). + - PB_VERSION image: drivervault-aio container_name: drivervault-aio restart: unless-stopped @@ -24,6 +26,9 @@ services: PB_ADMIN_PASSWORD: "${PB_ADMIN_PASSWORD:?set PB_ADMIN_PASSWORD in .env}" # Match CORS to the web origin (only used if a browser calls the API directly). CORS_ALLOW_ORIGINS: "${CORS_ALLOW_ORIGINS:-http://localhost:8090}" + # Probed by the panel status page. nginx serves the Web App on port 80 + # inside this container, so the default (localhost:8090) would never answer. + WEBAPP_URL: "http://127.0.0.1:80" # Schema + super-admin bootstrap on boot (idempotent). Set PB_BOOTSTRAP=false # to skip once the database is established. PB_BOOTSTRAP: "${PB_BOOTSTRAP:-true}" @@ -44,6 +49,14 @@ services: - pb_data:/pb/pb_data # plugins.json + the .env the panel writes back. - api_data:/data + healthcheck: + # All three processes must answer. Declared here as well as in the image so + # the check is visible, and works against an older pulled image. + test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:8070/api/health >/dev/null && wget -qO- http://127.0.0.1:8080/healthz >/dev/null && wget -qO- http://127.0.0.1:80/healthz >/dev/null || exit 1"] + interval: 30s + timeout: 5s + retries: 3 + start_period: 60s volumes: pb_data: diff --git a/Docker/.env.example b/Docker/.env.example index 4e6addd..4e9752e 100644 --- a/Docker/.env.example +++ b/Docker/.env.example @@ -4,6 +4,17 @@ 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 + +# Set to false to skip schema creation/reconcile once the database is set up. +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. diff --git a/Docker/docker-compose.prod.yml b/Docker/docker-compose.prod.yml index b21d04e..e45745e 100644 --- a/Docker/docker-compose.prod.yml +++ b/Docker/docker-compose.prod.yml @@ -53,6 +53,10 @@ services: 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 it must be the service name — the default + # (localhost:8090) would resolve to this container itself. + 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). Set PB_BOOTSTRAP=false to @@ -76,19 +80,36 @@ services: # plugins.json + the .env the panel writes back — see the API Server # Dockerfile. Without this, plugin state is lost on container recreate. - "${API_DATA:-api_data}:/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: - - api-server + # 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/* to the API Server over the internal network. API_BASE: "http://api-server:8080" 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: diff --git a/Docker/docker-compose.yml b/Docker/docker-compose.yml index 613f794..b0595e1 100644 --- a/Docker/docker-compose.yml +++ b/Docker/docker-compose.yml @@ -42,10 +42,21 @@ services: 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 it must be the service name — the default + # (localhost:8090) would resolve to this container itself. + 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. Set PB_BOOTSTRAP=false to skip once established. + 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. @@ -61,6 +72,14 @@ services: # Dockerfile. Without this, plugin state is lost when the container is # recreated. - api_data:/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: @@ -72,12 +91,21 @@ services: container_name: drivervault-web restart: unless-stopped depends_on: - - api-server + # 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/* to the API Server over the internal network. API_BASE: "http://api-server:8080" 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: diff --git a/Docker/pocketbase/Dockerfile b/Docker/pocketbase/Dockerfile index 60f808c..78bfd46 100644 --- a/Docker/pocketbase/Dockerfile +++ b/Docker/pocketbase/Dockerfile @@ -1,10 +1,13 @@ # syntax=docker/dockerfile:1 -# PocketBase built from the official release binary on alpine:latest. -FROM alpine:latest +# PocketBase built from the official release binary. +FROM alpine:3.24 -# Pin a version, or leave empty to fetch the latest release at build time. -ARG PB_VERSION="" +# 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" diff --git a/Web App/Dockerfile b/Web App/Dockerfile index 8141cfd..6e61535 100644 --- a/Web App/Dockerfile +++ b/Web App/Dockerfile @@ -8,7 +8,7 @@ # Build context is the "Web App" directory (see Docker/docker-compose.yml). # --- Stage 1: build the Vue SPA --------------------------------------------- -FROM node:22-alpine AS web-build +FROM node:22-alpine3.24 AS web-build WORKDIR /web COPY web/package.json web/package-lock.json ./ RUN npm ci @@ -23,7 +23,7 @@ ENV VITE_API_BASE=${VITE_API_BASE} RUN npm run build -- --outDir dist --emptyOutDir # --- Stage 2: build the Go BFF, embedding the SPA --------------------------- -FROM golang:1.26-alpine AS server-build +FROM golang:1.26-alpine3.24 AS server-build WORKDIR /src COPY server/go.mod ./ # go.sum is optional (stdlib-only module today); copy it if present. @@ -35,7 +35,7 @@ COPY --from=web-build /web/dist ./dist RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -o /out/web-bff . # --- Runtime stage ---------------------------------------------------------- -FROM alpine:latest +FROM alpine:3.24 RUN apk add --no-cache ca-certificates tzdata \ && addgroup -S app && adduser -S -G app app @@ -48,4 +48,11 @@ ENV WEB_ADDR=:8090 \ EXPOSE 8090 USER app + +# /healthz is a real route on the BFF (not the SPA fallback), so this fails if +# the server stops serving. It deliberately does not probe API_BASE: an API +# Server outage should surface on the panel status page, not kill this container. +HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ + CMD wget -qO- http://127.0.0.1:8090/healthz >/dev/null 2>&1 || exit 1 + ENTRYPOINT ["/app/web-bff"] diff --git a/Web App/server/main.go b/Web App/server/main.go index 355fa2c..71dc75d 100644 --- a/Web App/server/main.go +++ b/Web App/server/main.go @@ -55,6 +55,16 @@ func main() { mux := http.NewServeMux() mux.Handle("/api/", proxy) + + // Liveness probe. The API Server polls this for the panel status page (see + // WEBAPP_URL), and container healthchecks use it. It must be a real route: + // without one the SPA catch-all below would answer with index.html, which + // looks healthy even when the proxy target is misconfigured. + mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/plain; charset=utf-8") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("ok")) + }) mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { // Serve static assets when they exist; otherwise fall back to index.html // so client-side routing works on deep links.