feat(docker): official single-container (all-in-one) image (#1042)
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.
This commit is contained in:
@@ -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/[email protected]
|
||||
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:
|
||||
|
||||
+151
@@ -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 <text>
|
||||
# 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 '<?xml version="1.0"?>\n<!DOCTYPE fontconfig SYSTEM "fonts.dtd">\n<fontconfig>\n <dir>/app/assets/fonts</dir>\n</fontconfig>\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"]
|
||||
@@ -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
|
||||
|
||||
+42
-1
@@ -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);
|
||||
|
||||
|
||||
+55
-30
@@ -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
|
||||
|
||||
@@ -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://<host>: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.
|
||||
Reference in New Issue
Block a user