# All-in-one image (#1042): one container, one Node process.
#
# The backend serves the built frontend itself via server.js's SERVE_FRONTEND
# block (SPA fallback, OG crawler intercept, brand-title render, immutable
# asset caching) — no nginx, no supervisor, no bundled Postgres/Redis. SQLite
# is the explicit default engine; pointing DB_HOST/DB_USER/DB_PASSWORD (+
# DATABASE_CLIENT=pg) at an external Postgres works exactly like the backend
# image. Build context is the REPO ROOT (both backend/ and frontend/ are
# needed): docker build -f Dockerfile.aio .
#
# KEEP IN SYNC: the runtime stage below mirrors backend/Dockerfile's
# production stage (base image, apk set, npm removal, nodejs user, fontconfig
# registration, directory layout, healthcheck, entrypoint). When
# backend/Dockerfile changes, change this file too — the aio smoke job in
# docker-build.yml catches boot-level drift, not package-level drift.
# ---------------------------------------------------------------------------
# Frontend build — mirrors frontend/Dockerfile's builder stage
# ---------------------------------------------------------------------------
FROM node:22-alpine AS frontend-builder
ARG CACHEBUST=1
WORKDIR /app
COPY frontend/package*.json ./
RUN npm ci --legacy-peer-deps
COPY frontend/ .
RUN npm run build
# ---------------------------------------------------------------------------
# Backend deps — mirrors backend/Dockerfile's builder stage
# ---------------------------------------------------------------------------
FROM node:22-alpine AS backend-builder
ARG CACHEBUST=1
WORKDIR /app
COPY backend/package*.json ./
RUN npm ci --omit=dev
# ---------------------------------------------------------------------------
# Runtime — mirrors backend/Dockerfile's production stage + the frontend dist
# ---------------------------------------------------------------------------
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 (backend + frontend, single container)"
LABEL org.opencontainers.image.licenses="MIT"
WORKDIR /app
# Explicit engine selection (#1038/#1042): SQLite is this image's DEFAULT
# engine — set explicitly, never inferred, and wait-for-db.sh skips its
# Postgres readiness wait for it. Point the container at an external Postgres
# by overriding DATABASE_CLIENT=pg and setting DB_HOST/DB_USER/DB_PASSWORD,
# exactly like the backend image. The boot resolver still logs the engine and
# refuses the populated-both conflict.
# STORAGE_PATH: getStoragePath() falls back to path.join(__dirname,
# '../../../storage') — which resolves to the container-root `/storage` here,
# writable by root but EACCES for the nodejs user after the su-exec drop.
# Compose masks this by setting STORAGE_PATH=/app/storage; this image must
# pin the same path (it is the directory the Dockerfile creates and chowns).
ENV NODE_ENV=production \
DATABASE_CLIENT=sqlite3
# See backend/Dockerfile for the rationale of each of the following blocks.
RUN echo "cachebust=${CACHEBUST}" && apk upgrade --no-cache
RUN rm -rf /usr/local/lib/node_modules/npm /usr/local/bin/npm /usr/local/bin/npx
# sqlite — DatabaseBackupService.createSQLiteBackup() SPAWNS the `sqlite3`
# CLI for `.backup` and PRAGMA integrity_check; the npm module does not
# ship that binary. backend/Dockerfile omits it because compose always runs
# Postgres — this image defaults to SQLite, so without it every database
# backup fails with ENOENT.
RUN apk add --no-cache dumb-init postgresql-client sqlite 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-builder --chown=nodejs:nodejs /app/node_modules ./node_modules
COPY --chown=nodejs:nodejs backend/ .
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 (#1042 scope: "single data layout on one volume")
# ---------------------------------------------------------------------------
# /data/db picpeak.db (+ -wal/-shm) and SETUP_TOKEN
# /data/storage originals, thumbnails, archives
# /data/logs application logs
# /data/backup built-in backup output; /backup symlinks here
#
# `-v picpeak:/data` and nothing else to remember — back up /data and you have
# backed up the install. /backup is where migrations 029 + 030 seed the backup
# destinations, so it is symlinked in rather than left dangling.
ENV DATA_ROOT=/data \
DATA_DIR=/data/db \
DATABASE_PATH=/data/db/picpeak.db \
STORAGE_PATH=/data/storage \
LOG_DIR=/data/logs \
BACKUP_DIR=/data/backup
# Share links are absolute only when a base URL is known: getFrontendBaseUrl()
# reads FRONTEND_URL, falls back to the general_site_url setting, and otherwise
# returns "" — which makes share_url come out as a bare "/gallery//"
# in API responses, QR codes and emails. docker-compose.yml defaults this to
# http://localhost:3000, but the documented one-liner for this image passes only
# JWT_SECRET, so without a default here every fresh single-container install
# would hand out unusable links. Same default as compose; override with
# -e FRONTEND_URL=https://photos.example.com, or set the site URL in Settings.
ENV FRONTEND_URL=http://localhost:3000
# /app/storage is a second entrance to the same volume. The business-document
# writers (quoteService, invoice sending/reminders, contract signatures) build
# their paths from `path.join(process.cwd(), 'storage', ...)` and never consult
# STORAGE_PATH. Compose hides that because it sets STORAGE_PATH=/app/storage
# with WORKDIR /app, so the two happen to be the same directory; here they are
# not, and /app is root-owned, so a quote or invoice PDF would fail to write as
# UID 1001 — and be lost with the container even if it succeeded. Teaching
# those services STORAGE_PATH is the real fix and belongs in its own change;
# the symlink restores the coincidence compose already relies on.
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 && \
ln -s /data/storage /app/storage && \
chown -R nodejs:nodejs /data
VOLUME ["/data"]
# The frontend bundle, served by server.js's SERVE_FRONTEND block. Explicit
# opt-in rather than the dist-exists autodetect, so the behavior is pinned
# even if the autodetect heuristic ever changes.
COPY --from=frontend-builder --chown=nodejs:nodejs /app/dist /app/frontend/dist
ENV SERVE_FRONTEND=true \
FRONTEND_DIR=/app/frontend/dist
# Marks this as the single-container build. The backend refuses to enable face
# recognition (#1074) when it sees this, on performance grounds: that feature
# needs a separate ML container this image does not contain, and it would add
# a second image-processing pipeline competing with Sharp for the CPU and
# memory of a container sized for one photographer plus guests browsing. The
# failure would not be loud — just a slow install that looks broken.
#
# An explicit marker rather than inferring it from SERVE_FRONTEND or the
# SQLite path: legitimate multi-container deployments do both of those, and
# none of them should lose the feature by accident.
ENV PICPEAK_SINGLE_CONTAINER=true
# No USER directive — same as backend/Dockerfile: the container starts as root
# so wait-for-db.sh can chown bind-mounted volumes to UID 1001, then drops
# privileges via su-exec (#484).
EXPOSE 3000
# Shell form so it resolves $PORT: a hard-coded 3000 marks an otherwise healthy
# container unhealthy forever the moment anyone overrides the port.
HEALTHCHECK --interval=30s --timeout=5s --start-period=60s --retries=3 \
CMD wget --no-verbose --tries=1 --spider "http://localhost:${PORT:-3000}/health" || exit 1
ENTRYPOINT ["dumb-init", "--"]
# --max-http-header-size matches nginx's `large_client_header_buffers 4 32k`.
# Requests reach Node directly here, and its 16 KiB default would reject a guest
# carrying several per-gallery JWT cookies before Express ever saw them.
CMD ["./wait-for-db.sh", "node", "--max-http-header-size=32768", "server.js"]