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:
Paul Nothaft
2026-08-16 22:03:58 +02:00
parent d62e21c1ba
commit 6769d79e08
6 changed files with 679 additions and 32 deletions
+284 -1
View File
@@ -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
View File
@@ -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"]
+5
View File
@@ -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
View File
@@ -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
View File
@@ -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
+142
View File
@@ -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.