Files
picpeak/Dockerfile.aio
T
Paul Nothaft b507932d69 fix(docker): address round-2 review of the all-in-one image (#1042)
- Reverted the engine-resolution reordering. Resolving before the PostgreSQL
  wait broke a real upgrade path: probePgData deliberately reports "occupied"
  when Postgres is unreachable, and the target database may not exist until the
  wait loop creates it — so an install that declares no DATABASE_CLIENT but has
  a populated legacy SQLite file would resolve to ambiguous-both-populated and
  refuse to boot. The resolver is back in its original position and the wait is
  now skipped only when sqlite3 is EXPLICITLY declared, which is what this image
  does. Installs that declare nothing keep today's ordering byte for byte.

- Backup subdirectories are created at startup. Creating /data/backup alone was
  not enough: on a bind mount the subdirectories baked into the image are
  hidden, and the backup services do not create them, so /backup/picpeak and
  /backup/database failed with ENOENT.

- BACKUP_DIR points at the real path, not the /backup symlink. `chown -R` on a
  symlink argument acts on the link, so the target under a bind-mounted /data
  was never adopted and the writability preflight failed as UID 1001.

- Branding is rendered at startup, not baked at build. A build-time sed fixed
  the literal-token bug but silently made BRAND_TITLE/BRAND_DESCRIPTION inert
  for this image. docker-entrypoint.aio.sh keeps index.html as a template and
  renders it with envsubst before exec'ing wait-for-db.sh — same contract, same
  defaults and same two-variable allowlist as the frontend image.

- /fonts/ added to the SPA fallback exclusions. Both font secureStatic mounts
  call next() on a miss, so a missing font was answering 200 text/html.

Not changed: the workflow_dispatch push opt-out. That push-decision block is
byte-identical in build-backend, build-aio and build-frontend, so it is
pre-existing behaviour rather than something this job introduces; the reviewer
agreed on being shown the evidence that fixing it only here would make AIO the
odd one out.

Verified on a rebuilt image, named volume and bind mount: both healthy, default
title "PicPeak" and BRAND_TITLE="Studio Nord Galerie" rendering in <title> and
og:title, /backup/{picpeak,database} present and writable on the host side.
2026-08-16 23:25:48 +02:00

180 lines
8.5 KiB
Docker

# 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)
# sqlite — DatabaseBackupService.createSQLiteBackup() spawns the `sqlite3`
# CLI for `.backup` and PRAGMA integrity_check. The npm sqlite3 module does
# not provide that executable. The compose backend image omits it because
# it always runs Postgres; this image defaults to SQLite, so without it the
# built-in database backup fails with ENOENT.
# gettext — envsubst, used by docker-entrypoint.aio.sh to render index.html
RUN apk add --no-cache dumb-init postgresql-client sqlite ffmpeg su-exec gettext \
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
# index.html ships with ${BRAND_TITLE} / ${BRAND_DESCRIPTION} placeholders that
# frontend/docker-entrypoint.sh renders at container start. This image runs no
# nginx and never invokes that entrypoint, so keep the built file as a template
# and render it on startup instead (docker-entrypoint.aio.sh) — baking the
# defaults at build time would fix the literal-token bug but silently make
# BRAND_TITLE/BRAND_DESCRIPTION inert for this image.
RUN mv ./public/index.html ./public/index.html.template
COPY --chown=nodejs:nodejs docker-entrypoint.aio.sh /usr/local/bin/docker-entrypoint.aio.sh
RUN chmod +x /usr/local/bin/docker-entrypoint.aio.sh
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 \
# Point the startup chown/preflight at the REAL directory, not the /backup
# symlink: `chown -R` on a symlink argument acts on the link itself, so the
# target under a bind-mounted /data would never be adopted and the
# writability preflight would fail as UID 1001.
BACKUP_DIR=/data/backup \
PORT=3000
# /backup is where migrations 029 + 030 seed the built-in backup destinations
# (/backup/picpeak and /backup/database). Nothing mounts it here, so it is
# symlinked into the volume: the seeded defaults keep working AND the archives
# land on /data instead of inside a container that gets replaced on upgrade.
RUN mkdir -p /data/db /data/storage/events/active /data/storage/events/archived \
/data/storage/thumbnails /data/logs \
/data/backup/picpeak /data/backup/database && \
ln -s /data/backup /backup && \
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 ["/usr/local/bin/docker-entrypoint.aio.sh", "node", "server.js"]