From 6769d79e080de404c149098cc5520e4820530005 Mon Sep 17 00:00:00 2001 From: Paul Nothaft Date: Sun, 16 Aug 2026 22:03:58 +0200 Subject: [PATCH] feat(docker): official single-container (all-in-one) image (#1042) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One `docker run`, one volume, working PicPeak — for the #705 audience on a NAS or small VPS, where the four-service compose stack is what loses against SaaS onboarding. Second of the two PRs planned there; #1041/#1043 shipped the upgrade path out of it first, so a small install is never a dead end. One Node process: no supervisor, no nginx, no Postgres, no Redis. SQLite is the explicit, documented default — a library in-process writing one file on the volume, no second daemon, no credentials, no pg_upgrade dance on image bumps. DB_* still points at an external Postgres; the engine is resolved and logged at boot (#1038). Everything under one mount: /data/db (database + SETUP_TOKEN), /data/storage, /data/logs. Four backend changes were needed to make this work, all of which also fix the existing SERVE_FRONTEND path: - wait-for-db.sh waited for PostgreSQL unconditionally, resolving the engine only afterwards. On SQLite that blocks forever on a host that will never answer. Engine resolution moves ahead of the wait and the wait is skipped for sqlite3. resolveBootEngine reads the filesystem and env, never a live connection, so it is safe to run first. - The writable-root checks hard-coded /app/storage, /app/data, /app/logs, so a single /data volume could not be guarded. They now follow the same STORAGE_PATH / DATA_DIR / LOG_DIR the app reads. - express.static sent no Cache-Control at all. nginx sets `immutable` on hashed assets and no-store on index.html; without the latter a stale index.html after an upgrade names chunks that no longer exist and the app won't boot. - The SPA history fallback only listed /admin/* and /gallery/*. nginx does `try_files $uri $uri/ /index.html`, so behind compose every route survived a reload — but direct hits on /setup, /impressum, /quote/:id, /transfer/:id, /invite/:token, /customer, /contract/:id, /payment-check and CMS /:slug all 404'd without it. /setup is the first URL a new install visits. The catch-all is registered after the API 404 handler, so /api/* still answers JSON. Header parity with frontend/nginx.conf was verified against a running container, not assumed: CSP, X-Frame-Options, X-Content-Type-Options, Referrer-Policy and Permissions-Policy all match (helmet already emitted them — nginx proxy_hide_header's its copies to avoid duplicates). CI publishes ghcr.io/.../aio with the same per-arch build -> manifest merge and Trivy-by-digest scanning as the other two images. Verified by building and running the image: SQLite boot healthy in ~6s, full setup wizard completed in a browser, admin dashboard live, data surviving container replacement, and an external-Postgres run healthy against a real Postgres 15. --- .github/workflows/docker-build.yml | 285 ++++++++++++++++++++++++++++- Dockerfile.aio | 151 +++++++++++++++ README.md | 5 + backend/server.js | 43 ++++- backend/wait-for-db.sh | 85 ++++++--- docs/single-container.md | 142 ++++++++++++++ 6 files changed, 679 insertions(+), 32 deletions(-) create mode 100644 Dockerfile.aio create mode 100644 docs/single-container.md diff --git a/.github/workflows/docker-build.yml b/.github/workflows/docker-build.yml index ea3ef590..9d1c48ea 100644 --- a/.github/workflows/docker-build.yml +++ b/.github/workflows/docker-build.yml @@ -342,6 +342,289 @@ jobs: run: | docker buildx imagetools inspect docker.io/picpeak/backend:${{ steps.meta-backend.outputs.version }} + # ----------------------------------------------------------------------------- + # Frontend: per-arch build, then merge into a multi-arch manifest + # ----------------------------------------------------------------------------- + # ----------------------------------------------------------------------------- + # All-in-one (#1042): backend + built frontend in a single image, SQLite default. + # Same per-arch build -> manifest merge shape as the other two, so it inherits + # the same Trivy-by-digest scanning rather than a weaker one-off publish. + # ----------------------------------------------------------------------------- + build-aio: + strategy: + fail-fast: false + matrix: + include: + - platform: linux/amd64 + runner: ubuntu-latest + - platform: linux/arm64 + runner: ubuntu-24.04-arm + runs-on: ${{ matrix.runner }} + permissions: + contents: read + packages: write + # Trivy uploads its SARIF to the Security tab from this job — see + # the "Run Trivy" step below. Scanning per-arch by digest (#476) + # is reliable; scanning the multi-arch index by tag from the + # merge-* job was not. + security-events: write + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Compute image names (lowercase for GHCR) + run: | + repo_lc="${GITHUB_REPOSITORY,,}" + echo "AIO_IMAGE_NAME=${repo_lc}/aio" >> "$GITHUB_ENV" + echo "FRONTEND_IMAGE_NAME=${repo_lc}/frontend" >> "$GITHUB_ENV" + # Mirror manifests to Docker Hub (picpeak/{backend,frontend}) only on the + # canonical org repo, where the DOCKERHUB_* secrets live. Forks (and any + # other owner) fall back to GHCR-only — the Docker Hub image line and login + # are gated on this flag so their builds keep working unchanged. + if [[ "$GITHUB_REPOSITORY" == "PicPeak/picpeak" ]]; then + echo "DOCKERHUB_ENABLED=true" >> "$GITHUB_ENV" + else + echo "DOCKERHUB_ENABLED=false" >> "$GITHUB_ENV" + fi + + - name: Prepare platform pair + run: | + platform="${{ matrix.platform }}" + echo "PLATFORM_PAIR=${platform//\//-}" >> "$GITHUB_ENV" + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to Container Registry + if: github.event_name != 'pull_request' || github.event.inputs.push == 'true' + id: login-ghcr + continue-on-error: true + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Determine if pushing + id: push-decision + run: | + if [[ "${{ github.event_name }}" == "pull_request" && "${{ github.event.inputs.push }}" != "true" ]]; then + echo "push=false" >> "$GITHUB_OUTPUT" + elif [[ "${{ steps.login-ghcr.outcome }}" != "success" ]]; then + echo "push=false" >> "$GITHUB_OUTPUT" + else + echo "push=true" >> "$GITHUB_OUTPUT" + fi + + - name: Extract metadata for Backend (labels only) + id: meta-backend + uses: docker/metadata-action@v5 + with: + images: ${{ env.REGISTRY }}/${{ env.AIO_IMAGE_NAME }} + labels: | + org.opencontainers.image.title=PicPeak All-in-One + org.opencontainers.image.description=PicPeak photo sharing platform backend service + org.opencontainers.image.vendor=PicPeak + maintainer=${{ github.repository_owner }} + + - name: Build Backend image (push by digest) + id: build + uses: docker/build-push-action@v5 + with: + context: . + file: ./Dockerfile.aio + platforms: ${{ matrix.platform }} + labels: ${{ steps.meta-backend.outputs.labels }} + cache-from: type=gha,scope=backend-${{ env.PLATFORM_PAIR }} + # ignore-error: a flaky GitHub Actions cache write ("error writing + # layer blob: not_found") must not fail an otherwise-successful build + # that already pushed the image. + cache-to: type=gha,mode=max,scope=backend-${{ env.PLATFORM_PAIR }},ignore-error=true + outputs: ${{ steps.push-decision.outputs.push == 'true' && format('type=image,name={0}/{1},push-by-digest=true,name-canonical=true,push=true', env.REGISTRY, env.AIO_IMAGE_NAME) || 'type=cacheonly' }} + build-args: | + CACHEBUST=${{ github.run_number }} + BUILD_DATE=${{ github.event.head_commit.timestamp }} + VCS_REF=${{ github.sha }} + VERSION=${{ steps.meta-backend.outputs.version }} + + - name: Export digest + if: steps.push-decision.outputs.push == 'true' + run: | + mkdir -p /tmp/digests + digest="${{ steps.build.outputs.digest }}" + touch "/tmp/digests/${digest#sha256:}" + + - name: Upload digest artifact + if: steps.push-decision.outputs.push == 'true' + uses: actions/upload-artifact@v4 + with: + name: digests-aio-${{ env.PLATFORM_PAIR }} + path: /tmp/digests/* + if-no-files-found: error + retention-days: 1 + + # Per-arch vulnerability scan (#476). Scanning the multi-arch + # manifest from the merge-* job by tag is unreliable — Trivy's + # remote resolver crashes intermittently with "no child with + # platform linux/amd64 in index". The fix is to scan each leg + # by its single-platform digest right here, where it just landed + # in GHCR. Tag pinned (was @master) so the action + bundled + # Trivy binary don't float between runs. + # + # exit-code is left unset (=0) for now: Trivy reports findings + # to the Security tab but doesn't fail the build. Flipping that + # to '1' to actually gate CI is a deliberate follow-up — needs an + # audit pass first so the next beta build doesn't surprise red. + - name: Run Trivy vulnerability scanner (per-arch, by digest) + if: steps.push-decision.outputs.push == 'true' + uses: aquasecurity/trivy-action@v0.36.0 + env: + # docker/build-push-action wraps every push in an OCI index + # (carries the SLSA provenance attestation alongside the + # actual image). Trivy's remote backend defaults to + # linux/amd64 regardless of host arch when resolving an + # index, which makes the arm64 leg crash with "no child + # with platform linux/amd64". Telling Trivy which child to + # scan keeps the provenance attestation intact and fixes + # the resolver crash. Pin to matrix.platform so each leg + # scans its own arch. + TRIVY_PLATFORM: ${{ matrix.platform }} + with: + image-ref: ${{ env.REGISTRY }}/${{ env.AIO_IMAGE_NAME }}@${{ steps.build.outputs.digest }} + format: 'sarif' + output: 'trivy-aio-${{ env.PLATFORM_PAIR }}.sarif' + severity: 'CRITICAL,HIGH' + timeout: '10m' + + - name: Upload Trivy scan results to GitHub Security tab + if: steps.push-decision.outputs.push == 'true' + uses: github/codeql-action/upload-sarif@v4 + with: + sarif_file: 'trivy-aio-${{ env.PLATFORM_PAIR }}.sarif' + # Distinct category per arch so the Security tab surfaces + # per-platform findings independently — an amd64-only CVE in + # a base layer doesn't get masked by the arm64 scan. + category: 'backend-vulnerabilities-${{ env.PLATFORM_PAIR }}' + + merge-aio: + needs: build-aio + runs-on: ubuntu-latest + # No security-events permission here — vulnerability scanning moved + # to per-arch build-aio jobs (#476). This job's only job is to + # combine the per-arch digests into a multi-arch manifest. + permissions: + contents: read + packages: write + # Only run when at least one digest was pushed (i.e. not on PRs without push intent). + if: github.event_name != 'pull_request' || github.event.inputs.push == 'true' + + steps: + - name: Compute image names (lowercase for GHCR) + run: | + repo_lc="${GITHUB_REPOSITORY,,}" + echo "AIO_IMAGE_NAME=${repo_lc}/aio" >> "$GITHUB_ENV" + echo "FRONTEND_IMAGE_NAME=${repo_lc}/frontend" >> "$GITHUB_ENV" + # Mirror manifests to Docker Hub (picpeak/{backend,frontend}) only on the + # canonical org repo, where the DOCKERHUB_* secrets live. Forks (and any + # other owner) fall back to GHCR-only — the Docker Hub image line and login + # are gated on this flag so their builds keep working unchanged. + if [[ "$GITHUB_REPOSITORY" == "PicPeak/picpeak" ]]; then + echo "DOCKERHUB_ENABLED=true" >> "$GITHUB_ENV" + else + echo "DOCKERHUB_ENABLED=false" >> "$GITHUB_ENV" + fi + + - name: Download digest artifacts + uses: actions/download-artifact@v4 + with: + path: /tmp/digests + pattern: digests-aio-* + merge-multiple: true + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to Container Registry + id: login-ghcr + continue-on-error: true + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Determine build context + id: context + run: | + if [[ "${{ github.ref }}" == refs/tags/v*-beta* ]] || [[ "${{ github.ref }}" == refs/heads/main ]]; then + # Active-dev branch (`main`, renamed from `beta` per #669) produces + # prereleases; the `-beta.N` version-suffix scheme is unchanged. + echo "channel=beta" >> $GITHUB_OUTPUT + echo "is_prerelease=true" >> $GITHUB_OUTPUT + else + echo "channel=stable" >> $GITHUB_OUTPUT + echo "is_prerelease=false" >> $GITHUB_OUTPUT + fi + + - name: Log in to Docker Hub + if: env.DOCKERHUB_ENABLED == 'true' + uses: docker/login-action@v3 + with: + registry: docker.io + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Extract metadata for Backend + id: meta-backend + uses: docker/metadata-action@v5 + with: + # GHCR always; Docker Hub (picpeak/aio) added on the canonical repo so + # the same tag scheme is mirrored to both registries. metadata-action drops + # the blank second line on forks → GHCR-only there. + images: | + ${{ env.REGISTRY }}/${{ env.AIO_IMAGE_NAME }} + ${{ env.DOCKERHUB_ENABLED == 'true' && 'docker.io/picpeak/aio' || '' }} + labels: | + org.opencontainers.image.title=PicPeak All-in-One + org.opencontainers.image.description=PicPeak photo sharing platform backend service + org.opencontainers.image.vendor=PicPeak + maintainer=${{ github.repository_owner }} + tags: | + type=ref,event=branch + type=ref,event=pr + type=semver,pattern={{version}} + type=semver,pattern={{major}}.{{minor}},enable=${{ steps.context.outputs.is_prerelease == 'false' }} + type=semver,pattern={{major}},enable=${{ steps.context.outputs.is_prerelease == 'false' }} + # #668/#783: publish the git-tag name verbatim (v3.45.0 / v3.84.0-beta.0) + # so users can pin the same string as the GitHub release. metadata-action's + # `pattern=v{{version}}` silently dropped it on prereleases, so use type=ref. + type=ref,event=tag + type=sha,format=short + # `:latest` + `:stable` follow the stable channel (the `stable` branch + + # stable release tags). The default branch is now `main` (active dev), + # so `is_default_branch` no longer maps to "stable" — be explicit. + type=raw,value=latest,enable=${{ github.ref == 'refs/heads/stable' || (startsWith(github.ref, 'refs/tags/v') && steps.context.outputs.is_prerelease == 'false') }} + type=raw,value=stable,enable=${{ github.ref == 'refs/heads/stable' || (startsWith(github.ref, 'refs/tags/v') && steps.context.outputs.is_prerelease == 'false') }} + # `:beta` is RETIRED post-rename (Option B / #669). Active-dev pulls + # are `:main` (auto via type=ref,event=branch). The pre-rename `:beta` + # tag remains frozen at its last build — operators should update. + + - name: Create and push multi-arch manifest + working-directory: /tmp/digests + run: | + docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \ + $(printf "${{ env.REGISTRY }}/${{ env.AIO_IMAGE_NAME }}@sha256:%s " *) + + - name: Inspect manifest (GHCR) + run: | + docker buildx imagetools inspect ${{ env.REGISTRY }}/${{ env.AIO_IMAGE_NAME }}:${{ steps.meta-backend.outputs.version }} + + - name: Inspect manifest (Docker Hub) + if: env.DOCKERHUB_ENABLED == 'true' + run: | + docker buildx imagetools inspect docker.io/picpeak/aio:${{ steps.meta-backend.outputs.version }} + # ----------------------------------------------------------------------------- # Frontend: per-arch build, then merge into a multi-arch manifest # ----------------------------------------------------------------------------- @@ -600,7 +883,7 @@ jobs: docker buildx imagetools inspect docker.io/picpeak/frontend:${{ steps.meta-frontend.outputs.version }} summary: - needs: [build-backend, merge-backend, build-frontend, merge-frontend] + needs: [build-backend, merge-backend, build-aio, merge-aio, build-frontend, merge-frontend] if: always() runs-on: ubuntu-latest permissions: diff --git a/Dockerfile.aio b/Dockerfile.aio new file mode 100644 index 00000000..0e5be7a5 --- /dev/null +++ b/Dockerfile.aio @@ -0,0 +1,151 @@ +# PicPeak all-in-one image (#1042) +# +# One `docker run`, one volume, working PicPeak. Aimed at the audience in #705: +# photographers on a Synology/UGREEN/QNAP NAS or a small VPS, for whom the +# supported minimum today — a four-service compose stack — is the thing that +# loses against SaaS onboarding. +# +# ONE NODE PROCESS. No supervisor, no bundled nginx/Postgres/Redis: +# +# * The backend already serves the built frontend (express.static + SPA +# fallback, gated on SERVE_FRONTEND/FRONTEND_DIR), and its helmet config +# already emits the same CSP and security headers as frontend/nginx.conf — +# nginx even proxy_hide_header's the backend's copies to avoid duplicates. +# So dropping nginx costs no header coverage; server.js adds the one thing +# nginx did that express.static doesn't (Cache-Control tiers). +# * SQLite is the explicit, documented default. It is a library inside this +# process writing one file on the volume: no second daemon, no credentials, +# no startup ordering, no initdb/pg_upgrade dance on image bumps — exactly +# the ops burden this image exists to remove. Point DB_HOST/DB_* at an +# external Postgres if you have one; wait-for-db.sh resolves the engine at +# boot (#1038) and logs which one it picked. +# * Redis is absent: the backend has no runtime dependency on it. +# +# One process also means clean PID-1 signal handling, one log stream, and one +# healthcheck — the reasons this is not a supervisord image. + +# --------------------------------------------------------------------------- +# Stage 1 — build the frontend +# --------------------------------------------------------------------------- +FROM node:22-alpine AS frontend-builder + +WORKDIR /build + +COPY frontend/package*.json ./ +RUN npm ci --legacy-peer-deps + +COPY frontend/ ./ +# The SPA talks to its API on relative paths, so nothing host-specific is baked +# in here — the same artifact works behind any hostname or reverse proxy. +RUN npm run build + +# --------------------------------------------------------------------------- +# Stage 2 — backend production dependencies +# --------------------------------------------------------------------------- +FROM node:22-alpine AS backend-deps + +WORKDIR /build + +COPY backend/package*.json ./ +RUN npm ci --omit=dev + +# --------------------------------------------------------------------------- +# Stage 3 — runtime +# --------------------------------------------------------------------------- +FROM node:22-alpine + +ARG CACHEBUST=1 +ARG BUILD_DATE +ARG VCS_REF +ARG VERSION + +LABEL org.opencontainers.image.source="https://github.com/PicPeak/picpeak" +LABEL org.opencontainers.image.description="PicPeak all-in-one (single container, SQLite default)" +LABEL org.opencontainers.image.licenses="MIT" + +WORKDIR /app + +# Same reasoning as backend/Dockerfile: knexfile picks its config block by +# NODE_ENV, and leaving it unset silently selects the sqlite3 development +# block while ignoring DB_* (#1038). Here SQLite is a supported choice rather +# than an accident — but it must be a DECLARED one, resolved and logged at +# boot, not a fallback nobody sees. +ENV NODE_ENV=production + +RUN echo "cachebust=${CACHEBUST}" && apk upgrade --no-cache + +# npm is removed for the same reason as the backend image: nothing runs it at +# runtime, and its bundled dependencies are a standing source of CVE-scanner +# noise. Use `node migrations/run-migrations-safe.js` rather than an npm script. +RUN rm -rf /usr/local/lib/node_modules/npm /usr/local/bin/npm /usr/local/bin/npx + +# Runtime packages mirror backend/Dockerfile. +# postgresql-client — the readiness loop in wait-for-db.sh IS `psql`, so +# without it an external-Postgres deployment fails the wait outright +# rather than degrading to the app's own connect. Only the SQLite default +# path skips that loop; leaving psql out would have made DB_HOST a trap. +# ffmpeg/ffprobe — video uploads (musl-native; the npm installer is glibc) +# fontconfig + fonts — sharp/librsvg rasterising SVG logos with live +# poppler-utils — pdftoppm, flattens inbound supplier PDFs server-side +# exiftool — pulls the embedded preview out of RAW/DNG uploads +# dumb-init — PID 1 signal handling +# su-exec — the root → nodejs privilege drop in wait-for-db.sh (#484) +RUN apk add --no-cache dumb-init postgresql-client ffmpeg su-exec \ + fontconfig ttf-dejavu ttf-liberation poppler-utils exiftool && \ + fc-cache -f + +RUN addgroup -g 1001 -S nodejs && adduser -S nodejs -u 1001 + +COPY --from=backend-deps --chown=nodejs:nodejs /build/node_modules ./node_modules +COPY --chown=nodejs:nodejs backend/ ./ + +# The built SPA. SERVE_FRONTEND/FRONTEND_DIR below point server.js at it. +COPY --from=frontend-builder --chown=nodejs:nodejs /build/dist ./public + +RUN chmod -R a+r /app && chmod +x wait-for-db.sh + +RUN printf '\n\n\n /app/assets/fonts\n\n' > /etc/fonts/conf.d/99-picpeak-fonts.conf && \ + fc-cache -f /app/assets/fonts + +# --------------------------------------------------------------------------- +# One volume, one layout +# --------------------------------------------------------------------------- +# Everything that must survive a container replacement lives under /data: +# +# /data/db picpeak.db (+ -wal/-shm) and SETUP_TOKEN +# /data/storage originals, thumbnails, archives +# /data/logs application logs +# +# A single mount point is the whole point — `-v picpeak:/data` and nothing +# else to remember. wait-for-db.sh reads these same three variables when it +# chowns and preflight-checks the writable roots, so the checks follow the +# layout instead of assuming /app/* (#1042). +ENV SERVE_FRONTEND=true \ + FRONTEND_DIR=/app/public \ + DATABASE_CLIENT=sqlite3 \ + DATA_DIR=/data/db \ + DATABASE_PATH=/data/db/picpeak.db \ + STORAGE_PATH=/data/storage \ + LOG_DIR=/data/logs \ + PORT=3000 + +RUN mkdir -p /data/db /data/storage/events/active /data/storage/events/archived \ + /data/storage/thumbnails /data/logs && \ + chown -R nodejs:nodejs /data + +VOLUME ["/data"] + +EXPOSE 3000 + +# /health verifies the database is reachable (it runs `SELECT 1`) and returns +# 503 when it isn't, so an unhealthy container reflects a real fault rather +# than just a live socket. No start-period padding for a Postgres wait is +# needed on the default SQLite path, but external-Postgres users get the same +# 60s grace as the compose image. +HEALTHCHECK --interval=30s --timeout=5s --start-period=60s --retries=3 \ + CMD wget --no-verbose --tries=1 --spider http://localhost:3000/health || exit 1 + +# No USER directive: the container starts as root so wait-for-db.sh can chown a +# bind-mounted /data to UID 1001 before dropping privileges via su-exec (#484). +ENTRYPOINT ["dumb-init", "--"] +CMD ["./wait-for-db.sh", "node", "server.js"] diff --git a/README.md b/README.md index 441b6feb..1db45e4f 100644 --- a/README.md +++ b/README.md @@ -45,6 +45,11 @@ Try PicPeak without installing anything — [demo.picpeak.app](https://demo.picp ## 🚀 Quick Start +> **On a NAS or a small VPS?** There's a single-container image — one +> `docker run`, one volume, SQLite by default, no compose file: +> **[docs/single-container.md](docs/single-container.md)**. You can move to the +> full stack later via a `.picpeak` export/import; it isn't a dead end. + Get PicPeak running in under 5 minutes: ```bash diff --git a/backend/server.js b/backend/server.js index 9bdafa8a..06b06d1e 100644 --- a/backend/server.js +++ b/backend/server.js @@ -876,6 +876,7 @@ app.use('/api/images', require('./src/routes/protectedImages')); app.use('/api/secure-images', secureImagesRoutes); // Optional: Serve built frontend (native installs) +let serveFrontendIndexPath = null; try { const serveFrontendEnv = process.env.SERVE_FRONTEND; // 'true' | 'false' | undefined const frontendDir = process.env.FRONTEND_DIR || path.join(__dirname, '../frontend/dist'); @@ -883,9 +884,30 @@ try { // Auto-serve when dist exists unless explicitly disabled const shouldServe = (serveFrontendEnv === 'true') || ((serveFrontendEnv === undefined || serveFrontendEnv === 'auto') && fs.existsSync(indexPath)); if (shouldServe) { + // Hoisted for the SPA history fallback registered after the API routes. + serveFrontendIndexPath = indexPath; logger.info(`Serving frontend from ${frontendDir}`); // Serve pre-built assets - app.use(express.static(frontendDir)); + // Cache-Control parity with frontend/nginx.conf (#1042). Without nginx + // in front — the single-container image — express.static sends no + // Cache-Control at all, which gets both tiers wrong: + // + // * Vite emits content-hashed files under /assets/. Those are safe to + // cache forever, and not doing so re-downloads the bundle every visit. + // * index.html must NEVER be cached: it names the current hashed + // chunks, so a stale copy after an upgrade points at files that no + // longer exist and the app won't boot until a hard refresh. + app.use(express.static(frontendDir, { + setHeaders: (res, filePath) => { + if (/[\\/]assets[\\/]/.test(filePath)) { + res.setHeader('Cache-Control', 'public, max-age=31536000, immutable'); + } else if (filePath.endsWith('index.html')) { + res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate'); + res.setHeader('Pragma', 'no-cache'); + res.setHeader('Expires', '0'); + } + }, + })); // Landing page handler or SPA fallback app.get('/', handlePublicSiteRequest, (req, res) => { @@ -927,6 +949,25 @@ try { // 404 handler for undefined API routes app.use('/api', notFoundHandler); +// SPA history fallback (#1042). nginx does `try_files $uri $uri/ /index.html`, +// so behind the compose stack every client-side route survives a reload or a +// pasted link. The Express side only listed /admin/* and /gallery/*, which was +// invisible while nginx sat in front — but the single-container image has no +// nginx, and a direct hit on /setup, /impressum, /quote/:id, /transfer/:id, +// /invite/:token, /customer, /contract/:id, /payment-check or a CMS /:slug +// returned a bare 404. /setup is the very first URL a new install visits. +// +// Registered here, AFTER the API routes and their JSON 404 handler above, so +// /api/* still answers with JSON — this only catches what nothing else did, +// which is exactly what try_files means. GET/HEAD only: a stray POST should +// still 404 rather than be handed an HTML page. +if (typeof serveFrontendIndexPath === 'string') { + app.get('*', (req, res, next) => { + if (req.path.startsWith('/api/')) return next(); + return res.sendFile(serveFrontendIndexPath); + }); +} + // Global error handler (must be last) app.use(errorHandler); diff --git a/backend/wait-for-db.sh b/backend/wait-for-db.sh index 581b692f..8a03b45f 100755 --- a/backend/wait-for-db.sh +++ b/backend/wait-for-db.sh @@ -25,9 +25,16 @@ unset _pair _var _file _cur # hard-coded nodejs user. Compose deployments that pin `user:` to something # other than root skip this branch — they own permissions themselves and hit # the preflight check below instead. +# The three writable roots. Defaults are the compose layout (/app/*); the +# single-container image (#1042) points all three under one mounted volume, +# so these must follow the same env vars the app itself reads rather than +# hard-coding /app — otherwise the checks below guard directories nothing uses. +DATA_DIRS="${STORAGE_PATH:-/app/storage} ${DATA_DIR:-/app/data} ${LOG_DIR:-/app/logs}" + if [ "$(id -u)" = "0" ]; then - if ! chown -R nodejs:nodejs /app/storage /app/data /app/logs 2>/dev/null; then - echo "ERROR: failed to chown /app/storage, /app/data, /app/logs to nodejs (UID 1001)." >&2 + # shellcheck disable=SC2086 — intentional word-splitting over the three roots + if ! chown -R nodejs:nodejs $DATA_DIRS 2>/dev/null; then + echo "ERROR: failed to chown $DATA_DIRS to nodejs (UID 1001)." >&2 echo " This usually means the host filesystem rejects chown (e.g. NFS without root squash" >&2 echo " disabled, or a SELinux/AppArmor policy blocking the operation)." >&2 echo " Workaround: pre-chown the host directories to 1001:1001 and pin 'user: \"1001:1001\"'" >&2 @@ -44,7 +51,8 @@ fi # followed by a confusing migration error and a restart loop. _uid="$(id -u)" _gid="$(id -g)" -for _dir in /app/storage /app/data /app/logs; do +# shellcheck disable=SC2086 — intentional word-splitting over the three roots +for _dir in $DATA_DIRS; do if [ ! -w "$_dir" ]; then echo "ERROR: $_dir is not writable by UID $_uid." >&2 echo " Either drop the 'user:' override from your compose file so the container starts as" >&2 @@ -55,6 +63,48 @@ for _dir in /app/storage /app/data /app/logs; do fi done +# Resolve which database engine this boot should use (#1038) BEFORE migrations +# run — and, since #1042, before the PostgreSQL readiness wait below, so a +# SQLite install never blocks on a Postgres that will never answer. +# +# An install that has been unknowingly running on SQLite (the image used to +# leave NODE_ENV unset, so +# knexfile.js fell back to sqlite3 and ignored DB_HOST/DB_USER/DB_PASSWORD) +# keeps serving from its SQLite file instead of coming up against an empty +# Postgres. The exported value survives the `exec` below, so the migration +# runner and the server agree on the engine. +RESOLVED_DB_CLIENT="$(node scripts/resolve-db-engine.js)" +RESOLVER_STATUS=$? +# Exit 3 means two populated databases with no record of which is current +# (#1038). Starting either would hide the other's data, so stop here — the +# resolver has already printed what to do. +if [ "$RESOLVER_STATUS" = "3" ]; then + exit 1 +fi +# Validate rather than trust: anything unexpected on stdout (a stray log line +# from a library that writes to the console) must not become DATABASE_CLIENT, +# which would break knexfile for every process that follows. +case "$RESOLVED_DB_CLIENT" in + pg|sqlite3) + export DATABASE_CLIENT="$RESOLVED_DB_CLIENT" + ;; + "") + >&2 echo "Database engine resolver returned nothing; falling back to the configured client." + ;; + *) + >&2 echo "Database engine resolver returned an unexpected value; ignoring it and falling back to the configured client." + ;; +esac + +# Everything below this point is PostgreSQL readiness. On SQLite the +# database is a file this process opens itself — there is no daemon to +# wait for — so the wait loop would block forever on a host that will +# never answer. That is exactly the single-container case (#1042), where +# SQLite is the documented default and no DB_HOST is set. +if [ "${DATABASE_CLIENT:-}" = "sqlite3" ]; then + echo "Database engine: sqlite3 — skipping the PostgreSQL readiness wait." +else + host="${DB_HOST:-postgres}" port="${DB_PORT:-5432}" user="${DB_USER:-picpeak}" @@ -123,6 +173,8 @@ done >&2 echo "Target database \"$target_db\" is ready." +fi + # Ensure storage directories exist with proper permissions (Issue #67 fix) # When host directories are bind-mounted, the container's built-in directories are overridden # This ensures the required directory structure exists before the application starts @@ -132,33 +184,6 @@ mkdir -p "$STORAGE_BASE/events/active" "$STORAGE_BASE/events/archived" "$STORAGE # Resolve which database engine this boot should use (#1038) BEFORE migrations # run, while the Postgres target is still untouched. An install that has been -# unknowingly running on SQLite (the image used to leave NODE_ENV unset, so -# knexfile.js fell back to sqlite3 and ignored DB_HOST/DB_USER/DB_PASSWORD) -# keeps serving from its SQLite file instead of coming up against an empty -# Postgres. The exported value survives the `exec` below, so the migration -# runner and the server agree on the engine. -RESOLVED_DB_CLIENT="$(node scripts/resolve-db-engine.js)" -RESOLVER_STATUS=$? -# Exit 3 means two populated databases with no record of which is current -# (#1038). Starting either would hide the other's data, so stop here — the -# resolver has already printed what to do. -if [ "$RESOLVER_STATUS" = "3" ]; then - exit 1 -fi -# Validate rather than trust: anything unexpected on stdout (a stray log line -# from a library that writes to the console) must not become DATABASE_CLIENT, -# which would break knexfile for every process that follows. -case "$RESOLVED_DB_CLIENT" in - pg|sqlite3) - export DATABASE_CLIENT="$RESOLVED_DB_CLIENT" - ;; - "") - >&2 echo "Database engine resolver returned nothing; falling back to the configured client." - ;; - *) - >&2 echo "Database engine resolver returned an unexpected value; ignoring it and falling back to the configured client." - ;; -esac # Run migrations (use safe runner in production). Invoked via node directly — # the runtime image no longer ships npm (see Dockerfile: its bundled deps kept diff --git a/docs/single-container.md b/docs/single-container.md new file mode 100644 index 00000000..eed6c815 --- /dev/null +++ b/docs/single-container.md @@ -0,0 +1,142 @@ +# PicPeak all-in-one (single container) + +One `docker run`, one volume, working PicPeak. Built for NAS boxes (Synology, +UGREEN, QNAP) and small VPSes where standing up a four-service compose stack is +the thing that makes people give up and go back to a SaaS. + +If you already run PostgreSQL, or you expect several photographers hitting the +admin UI at once, use the [compose stack](../README.md) instead. This image is +the small end of the range, not a replacement for it. + +## Run it + +```bash +docker run -d \ + --name picpeak \ + -p 3000:3000 \ + -v picpeak:/data \ + -e JWT_SECRET="$(openssl rand -base64 48)" \ + ghcr.io/picpeak/picpeak/aio:stable +``` + +Open `http://:3000`. The first visit lands on the setup wizard, which +asks for a one-time token: + +```bash +docker exec picpeak cat /data/db/SETUP_TOKEN +``` + +The token is also printed to the container log on first start. + +`JWT_SECRET` is the only variable you must set. Generate it once and keep it — +changing it invalidates every existing session and gallery link. + +## What is inside + +One Node process. No supervisor, no nginx, no PostgreSQL, no Redis. + +The backend serves the built frontend directly and applies the same security +headers the nginx container applies in the compose stack — same CSP, same +`X-Frame-Options`, `X-Content-Type-Options`, `Referrer-Policy` and +`Permissions-Policy`, plus the same cache tiers (hashed assets immutable, +`index.html` never cached). + +**SQLite is the default**, deliberately. It is a library inside the process +writing one file on your volume: no second daemon, no credentials, no startup +ordering, and no `pg_upgrade` dance when you pull a newer image. The engine is +resolved and logged at boot, so `docker logs` always tells you which database +you are actually on: + +``` +Database engine: sqlite (/data/db/picpeak.db) +``` + +Redis is absent — nothing in the backend needs it at runtime. + +## The volume + +Everything that must survive a container replacement lives under `/data`: + +| Path | Contents | +|---|---| +| `/data/db` | `picpeak.db` (+ `-wal`/`-shm`) and `SETUP_TOKEN` | +| `/data/storage` | originals, thumbnails, archives | +| `/data/logs` | application logs | + +One mount point is the whole point. Back up `/data` and you have backed up the +install. + +Upgrades are `docker pull` + recreate the container; migrations run at start. +The volume is what carries your data across, so never bind-mount a directory +you are about to delete. + +## Environment + +Only `JWT_SECRET` is required. Everything else has a working default. + +| Variable | Default | Notes | +|---|---|---| +| `JWT_SECRET` | — | **Required.** Long random string. | +| `PORT` | `3000` | Listen port inside the container. | +| `FRONTEND_URL` | — | Public URL. Set it once you are behind a domain, so emails and share links point at the right host. | +| `SMTP_*` | — | Outbound email. Without it, PicPeak runs fine but sends nothing. | +| `DB_HOST`, `DB_USER`, `DB_PASSWORD`, `DB_NAME` | — | Point at an **external** PostgreSQL. Setting these switches the engine off SQLite. | + +### Using an external PostgreSQL + +```bash +docker run -d --name picpeak -p 3000:3000 -v picpeak:/data \ + -e JWT_SECRET="…" \ + -e DATABASE_CLIENT=pg \ + -e DB_HOST=10.0.0.5 -e DB_USER=picpeak -e DB_PASSWORD=… -e DB_NAME=picpeak \ + ghcr.io/picpeak/picpeak/aio:stable +``` + +The image waits for the database to accept connections before running +migrations, exactly as the compose backend does. + +## TLS + +None is included. Terminate TLS in front of it — your NAS's reverse proxy, +Caddy, nginx, or a Cloudflare Tunnel. Set `FRONTEND_URL` to the public +`https://…` address so generated links match. + +## NAS notes + +**Synology (Container Manager)** and **QNAP (Container Station)** can both run +this from the registry UI: pull `ghcr.io/picpeak/picpeak/aio:stable`, map a +host port to container port `3000`, and add one volume mapping to `/data`. +Set `JWT_SECRET` under Environment. + +Point the volume at a folder on your data pool, not the system partition, and +prefer a folder you own — the container starts as root only long enough to +adopt the directory, then drops to UID 1001. + +## Outgrowing it + +A single-container install is never a dead end. When you need the full stack: + +1. **Settings → Backup → Export `.picpeak`** (include photos). +2. Stand up the compose stack with PostgreSQL and run its setup wizard. +3. **Settings → Backup → Restore** the `.picpeak` file. + +SQLite → PostgreSQL restore is supported (#1041); the reverse is not. Your +galleries, settings, customers and photos come across. + +## Health + +`/health` returns 200 when the database is reachable and 503 when it is not, +and the image's `HEALTHCHECK` uses it — so `docker ps` showing `healthy` +means the app can actually serve, not merely that a socket is open. + +```bash +docker inspect --format='{{.State.Health.Status}}' picpeak +``` + +## Limits + +- **SQLite means one writer.** Fine for one photographer plus guests + browsing; if several admins upload simultaneously all day, move to + PostgreSQL. +- **No built-in TLS or reverse proxy.** +- **No Redis**, so nothing here scales horizontally — run one container.