feat(deploy): make the all-in-one image installable without a shell (#1124)
The all-in-one image could not be installed from a GUI at all — the deployment it
exists for. validateEnv treats a missing JWT_SECRET as critical and exits, and the
documented run command supplies it with `openssl rand`, a shell command a Synology
Container Manager or QNAP Container Station form cannot run.
wait-for-db.sh now generates one on first start and persists it next to the database,
extending the existing /run/secrets hydration rather than adding a second mechanism.
Explicit env still wins, then /run/secrets, then the generated file. The write is
load-bearing: JWT_SECRET is exported only when the file actually persisted, because an
unpersisted secret would mint a new one every restart and sign every session out.
Creation writes to a private temp file and hard-links it into place — atomic, fails with
EEXIST when another container won, and the loser adopts the winner's value. Non-regular
paths are rejected before the link, since POSIX ln links INTO a directory rather than
failing, which would make a mistyped -v target unrecoverable.
Also repairs the onboarding paths a new install actually walks: the installer no longer
rotates the secrets of a running install on re-run, deprecates the dead scripts/install.sh
in place, corrects the CONTRIBUTING dev loop, and fixes the vite proxy target that had
been pointing at a stray local port since 0da45e69.
Reviewed over three rounds. Co-authored by @Luca-Timo.
This commit is contained in:
@@ -1005,6 +1005,63 @@ jobs:
|
||||
docker exec aio sh -c 'touch /backup/database/.w && rm /backup/database/.w' \
|
||||
|| { echo "::error::/backup/database is not writable by the app user"; exit 1; }
|
||||
|
||||
- name: Assert a no-JWT_SECRET install boots and keeps its generated secret
|
||||
run: |
|
||||
# The boot above passes JWT_SECRET explicitly, so it cannot catch the case
|
||||
# this image is actually deployed in: a NAS Container Manager / Container
|
||||
# Station form has no shell to run `openssl rand`, and validateEnv treats a
|
||||
# missing JWT_SECRET as critical and exits. wait-for-db.sh generates one
|
||||
# into the data volume when neither the environment nor /run/secrets
|
||||
# supplies it (#705).
|
||||
docker volume create aio-nojwt > /dev/null
|
||||
docker run -d --name aio-nojwt -p 3100:3000 -v aio-nojwt:/data picpeak-aio:smoke > /dev/null
|
||||
for i in $(seq 1 60); do
|
||||
curl -fsS http://localhost:3100/health > /dev/null 2>&1 && break
|
||||
sleep 2
|
||||
done
|
||||
curl -fsS http://localhost:3100/health > /dev/null 2>&1 \
|
||||
|| { echo "::error::container with no JWT_SECRET never became healthy"; docker logs aio-nojwt | tail -50; exit 1; }
|
||||
|
||||
mode=$(docker exec aio-nojwt stat -c '%a' /data/db/jwt.secret 2>/dev/null || echo missing)
|
||||
[ "$mode" = "600" ] \
|
||||
|| { echo "::error::/data/db/jwt.secret missing or not 0600 (got: $mode)"; exit 1; }
|
||||
|
||||
# The secret has to survive a restart. If it did not, every admin session
|
||||
# and gallery link would be signed out on each container recreate — which
|
||||
# is worse than failing to boot, because it looks like it works.
|
||||
first=$(docker exec aio-nojwt cat /data/db/jwt.secret)
|
||||
[ -n "$first" ] || { echo "::error::generated jwt.secret is empty"; exit 1; }
|
||||
docker restart aio-nojwt > /dev/null
|
||||
for i in $(seq 1 60); do
|
||||
curl -fsS http://localhost:3100/health > /dev/null 2>&1 && break
|
||||
sleep 2
|
||||
done
|
||||
# Assert recovery rather than falling through to `docker exec`, which
|
||||
# would report a confusing cat failure for a container that never came
|
||||
# back up.
|
||||
curl -fsS http://localhost:3100/health > /dev/null 2>&1 \
|
||||
|| { echo "::error::container did not become healthy again after restart"; docker logs aio-nojwt | tail -50; exit 1; }
|
||||
second=$(docker exec aio-nojwt cat /data/db/jwt.secret)
|
||||
[ "$first" = "$second" ] \
|
||||
|| { echo "::error::jwt.secret changed across a restart — sessions would not survive"; exit 1; }
|
||||
|
||||
# An explicit secret must still win over the generated one. The $ is
|
||||
# escaped so the comparison happens in the INNERMOST shell, after
|
||||
# wait-for-db.sh has run. Unescaped it is the CONTAINER's outer `sh -c`
|
||||
# that expands it — the runner's single quotes do protect it, but the
|
||||
# container shell then substitutes the value while parsing the
|
||||
# double-quoted region, before the script runs at all. The test reduces
|
||||
# to `[ x = x ]` and passes even against a script that clobbers the
|
||||
# variable: an assertion that cannot fail.
|
||||
docker run --rm -v aio-nojwt:/data -e JWT_SECRET=explicit-secret-at-least-32-characters-long \
|
||||
--entrypoint sh picpeak-aio:smoke -c \
|
||||
'./wait-for-db.sh sh -c "[ \"\$JWT_SECRET\" = explicit-secret-at-least-32-characters-long ]"' \
|
||||
|| { echo "::error::an explicit JWT_SECRET did not override the generated file"; exit 1; }
|
||||
|
||||
docker rm -f aio-nojwt > /dev/null
|
||||
# -f alone can return before the volume is released
|
||||
for i in $(seq 1 10); do docker volume rm aio-nojwt > /dev/null 2>&1 && break; sleep 1; done
|
||||
|
||||
- name: Assert the sqlite3 CLI the backup service shells out to
|
||||
run: |
|
||||
# DatabaseBackupService spawns `sqlite3` for .backup and integrity_check;
|
||||
|
||||
+20
-11
@@ -73,23 +73,32 @@ cd picpeak
|
||||
# Install dependencies
|
||||
cd backend && npm install
|
||||
cd ../frontend && npm install
|
||||
cd ..
|
||||
|
||||
# Set up environment
|
||||
cp .env.example .env
|
||||
# Edit .env with your settings
|
||||
# Start Postgres and Redis (the app itself runs on the host, see below)
|
||||
docker compose up -d postgres redis
|
||||
|
||||
# Start development servers
|
||||
docker-compose -f docker-compose.dev.yml up
|
||||
# Backend config — note this is backend/.env, not the root one
|
||||
cp backend/.env.example backend/.env
|
||||
# JWT_SECRET must be set: the host process validates it and exits without one.
|
||||
# (The containers generate it themselves; `npm run dev` does not.)
|
||||
|
||||
# Backend, with nodemon hot reload — http://localhost:3001
|
||||
cd backend && npm run dev
|
||||
|
||||
# Frontend, with Vite hot reload, in a second shell — http://localhost:5173
|
||||
cd frontend && npm run dev
|
||||
```
|
||||
|
||||
**After pulling changes that touch `backend/package.json` / `backend/package-lock.json` (or the frontend equivalents)**, rebuild the affected image so the live-mounted source can `require()` the new deps:
|
||||
Open **http://localhost:5173**. Vite proxies `/api` to the backend on `3001`, so
|
||||
you do not need the root `.env` for this loop at all — that one configures the
|
||||
compose stack.
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.dev.yml up -d --build backend
|
||||
# (or `frontend`, or both)
|
||||
```
|
||||
Running the two Node processes on the host is the fastest loop: both reload on save, and you get a real debugger and stack traces without rebuilding an image.
|
||||
|
||||
The dev compose bakes `node_modules` into the image while live-mounting `./backend/src` and `./frontend/src` from disk. A dep added on disk won't be picked up until the image is rebuilt — typical symptom is a `MODULE_NOT_FOUND` restart loop on the affected container.
|
||||
**Prefer everything in containers?** `docker compose up -d` builds `backend`, `frontend` and `ml` from source using the production Dockerfiles. That works, but there is no hot reload — you rebuild on every change (`docker compose up -d --build backend`).
|
||||
|
||||
> `docker-compose.dev.yml` is listed in `.gitignore` and is not part of the repo. If you keep a local one for live-mounting `./backend/src` and `./frontend/src` against `backend/Dockerfile.dev` / `frontend/Dockerfile.dev`, remember it bakes `node_modules` into the image: after pulling a change to `backend/package.json`, rebuild that image or you will get a `MODULE_NOT_FOUND` restart loop.
|
||||
|
||||
### Running Tests
|
||||
|
||||
|
||||
@@ -75,13 +75,14 @@ For a home server, a NAS, or a single small studio, the all-in-one image runs th
|
||||
```bash
|
||||
docker run -d --name picpeak -p 3000:3000 \
|
||||
-v picpeak:/data \
|
||||
-e JWT_SECRET="$(openssl rand -base64 48)" \
|
||||
ghcr.io/picpeak/picpeak/aio:main
|
||||
```
|
||||
|
||||
Then open **http://localhost:3000/admin** and read the setup token with `docker exec picpeak cat /data/db/SETUP_TOKEN`.
|
||||
No environment variables to set — the JWT secret is generated on first start and kept on the volume.
|
||||
|
||||
`:main` is the active-development tag, and today it is the only one the all-in-one image has — `Dockerfile.aio` landed after the current stable release, so `:stable` and `:latest` first appear for this image once the aio build reaches the `stable` branch. Switch to `:stable` then, or pin a version tag (`3.107.4-beta.0`) if you would rather not track `main`.
|
||||
Then open **http://localhost:3000/admin** and read the setup token with `docker exec picpeak cat /data/db/SETUP_TOKEN`, or open `db/SETUP_TOKEN` on the volume with any file manager if the host has no shell.
|
||||
|
||||
`:main` is the active-development tag, and today it is the only one the all-in-one image has — `Dockerfile.aio` landed after the current stable release, so `:stable` and `:latest` first appear for this image once the aio build reaches the `stable` branch. Switch to `:stable` then, or pin a published version tag if you would rather not track `main`.
|
||||
|
||||
The compose stack above is still the right choice for anything busier — SQLite takes one writer at a time, and Postgres is what scales. You can move to it later without reinstalling: take a `.picpeak` backup and restore it into the full stack. See **[Single-container install](https://docs.picpeak.app/deployment/single-container)** for the volume layout, the external-Postgres variant, TLS, and the limits.
|
||||
|
||||
|
||||
@@ -60,6 +60,133 @@ DATA_ROOT_DIR="${DATA_ROOT:-}"
|
||||
# shellcheck disable=SC2086 — intentional word-splitting over the roots
|
||||
mkdir -p $DATA_ROOT_DIR $DATA_DIRS 2>/dev/null || true
|
||||
|
||||
# Last resort for JWT_SECRET, after the /run/secrets pass above: generate one
|
||||
# and persist it next to the database. Deliberately placed here rather than
|
||||
# beside that loop because it needs DATA_DIR to exist, and before the chown
|
||||
# below so the new file is adopted along with everything else.
|
||||
#
|
||||
# The compose stack never reaches this — `secrets-init` has already written
|
||||
# /run/secrets/jwt_secret. It exists for deployments with no shell: the
|
||||
# all-in-one image mounts no secrets volume, and the documented one-liner asks
|
||||
# the user to run `openssl rand` on the HOST, which a NAS Container Manager or
|
||||
# Container Station form cannot do. Without this, JWT_SECRET is unset,
|
||||
# validateEnv treats it as critical, and the container exits — so the whole
|
||||
# GUI-driven install path is a dead end (#705).
|
||||
#
|
||||
# JWT_SECRET only. DB_PASSWORD must match whatever the Postgres volume was
|
||||
# initialised with, so inventing one at boot would break the connection rather
|
||||
# than fix it; the AIO default engine is SQLite and Redis is not used here.
|
||||
#
|
||||
# Losing the file invalidates every session and gallery link. Note the built-in
|
||||
# backup does NOT carry it — backupService archives STORAGE_PATH and the
|
||||
# database dump, not DATA_DIR — so only a copy of the whole volume preserves it.
|
||||
if [ -z "${JWT_SECRET:-}" ]; then
|
||||
_jwt_file="${DATA_DIR:-/app/data}/jwt.secret"
|
||||
|
||||
# Read, trim, and length-check rather than testing that the file merely
|
||||
# exists. A short write (ENOSPC, a SIGKILL mid-boot) leaves a partial file
|
||||
# that a `-s` test accepts on every later boot, and validateEnv only WARNS
|
||||
# below 32 characters — so the install would sign with a truncated key
|
||||
# indefinitely and nothing would say so. Whitespace is stripped first because
|
||||
# validateEnv rejects an all-blank secret as critical, and an all-blank file
|
||||
# long enough to pass a naive length test would boot-loop forever.
|
||||
_jwt_read() {
|
||||
[ -f "$1" ] || return 0
|
||||
tr -d '\r\n\t ' < "$1" 2>/dev/null || true
|
||||
}
|
||||
|
||||
# Loop rather than a single pass, because the two failure causes look
|
||||
# identical on the first read and need opposite responses: a file that is
|
||||
# absent-then-present is a concurrent boot winning the race (adopt it), while
|
||||
# a file that is still unusable after we have tried to link and re-read is
|
||||
# genuine corruption (clear it and retry). Removing on the first pass would
|
||||
# delete a perfectly good secret another container had just created.
|
||||
_jwt_made=""
|
||||
_jwt_cur="$(_jwt_read "$_jwt_file")"
|
||||
_jwt_try=0
|
||||
while [ "${#_jwt_cur}" -lt 32 ] && [ "$_jwt_try" -lt 2 ]; do
|
||||
_jwt_try=$((_jwt_try + 1))
|
||||
|
||||
if [ -e "$_jwt_file" ] && [ ! -f "$_jwt_file" ]; then
|
||||
# A directory (or anything non-regular) has to go BEFORE the link is
|
||||
# attempted, not after: POSIX `ln src dir` links src INTO the directory
|
||||
# instead of failing, so the first attempt would create
|
||||
# jwt.secret/jwt.secret.<sfx>.tmp, leave the directory non-empty, and make
|
||||
# rmdir impossible from then on — the container could never recover from a
|
||||
# mistyped `-v` target without host access. Verified against the runtime
|
||||
# image; busybox ln does exactly this.
|
||||
echo "[secrets] $_jwt_file is not a regular file — removing it." >&2
|
||||
rmdir "$_jwt_file" 2>/dev/null || rm -f "$_jwt_file" 2>/dev/null || true
|
||||
elif [ "$_jwt_try" -gt 1 ] && [ -f "$_jwt_file" ]; then
|
||||
# Re-read immediately before unlinking. Our earlier read may be stale:
|
||||
# another container can have linked a perfectly good secret in between,
|
||||
# and deleting it would leave the two of us signing with different keys.
|
||||
_jwt_cur="$(_jwt_read "$_jwt_file")"
|
||||
[ "${#_jwt_cur}" -ge 32 ] && break
|
||||
echo "[secrets] $_jwt_file is unusable — regenerating." >&2
|
||||
rm -f "$_jwt_file" 2>/dev/null || true
|
||||
fi
|
||||
|
||||
# Still not a regular file (a non-empty directory we refuse to delete):
|
||||
# nothing further will work, so stop and let the warning below explain.
|
||||
if [ -e "$_jwt_file" ] && [ ! -f "$_jwt_file" ]; then
|
||||
break
|
||||
fi
|
||||
|
||||
# openssl is not in the runtime image; /dev/urandom + base64 always are.
|
||||
# 48 bytes -> 64 base64 chars, comfortably past the 32 above.
|
||||
_jwt_new="$(head -c 48 /dev/urandom | base64 | tr -d '\n' || true)"
|
||||
|
||||
# Two properties are needed at once, and each idiom alone gives only one:
|
||||
#
|
||||
# * the file must never be READABLE half-written, or a concurrent boot
|
||||
# reads a partial secret — a plain `set -C` redirect creates the inode
|
||||
# first and writes after, so a loser can read it in between
|
||||
# * the create must not CLOBBER, or two containers each believe they own
|
||||
# the secret and sign with different keys — which is what `mv -f` does
|
||||
#
|
||||
# Writing the full content to a private temp file and hard-linking it into
|
||||
# place gives both: the link is atomic, fails with EEXIST when another
|
||||
# container already won, and the content is complete before the name exists.
|
||||
#
|
||||
# The suffix must be random rather than $$: the PID namespace makes $$ equal
|
||||
# to 1 in every container, so containers sharing a volume would all pick the
|
||||
# same temp path — and since a hard link shares the INODE, a second process
|
||||
# truncating that path writes straight through the linked secret.
|
||||
if [ -n "$_jwt_new" ]; then
|
||||
_jwt_sfx="$(head -c 12 /dev/urandom | base64 | tr -dc 'a-z0-9' | cut -c1-10 || true)"
|
||||
[ -n "$_jwt_sfx" ] || _jwt_sfx="$$"
|
||||
_jwt_tmp="$_jwt_file.$_jwt_sfx.tmp"
|
||||
if (umask 077; printf '%s\n' "$_jwt_new" > "$_jwt_tmp") 2>/dev/null \
|
||||
&& ln "$_jwt_tmp" "$_jwt_file" 2>/dev/null; then
|
||||
_jwt_made=yes
|
||||
fi
|
||||
rm -f "$_jwt_tmp" 2>/dev/null || true
|
||||
fi
|
||||
|
||||
# Trust the file, never the value in hand — whoever won the link is the
|
||||
# source of truth for every container on this volume, and the loser adopts
|
||||
# it rather than signing with the value it happened to generate.
|
||||
_jwt_cur="$(_jwt_read "$_jwt_file")"
|
||||
done
|
||||
|
||||
if [ "${#_jwt_cur}" -ge 32 ]; then
|
||||
export JWT_SECRET="$_jwt_cur"
|
||||
if [ -n "$_jwt_made" ]; then
|
||||
echo "[secrets] No JWT_SECRET supplied — generated one and stored it in $_jwt_file."
|
||||
echo "[secrets] Back up the whole data volume to keep it; the built-in backup does"
|
||||
echo "[secrets] not include it, and losing it signs every admin session and gallery"
|
||||
echo "[secrets] link out."
|
||||
fi
|
||||
else
|
||||
echo "WARNING: no JWT_SECRET supplied and $_jwt_file could not be created." >&2
|
||||
echo " Startup will fail validation. Pass -e JWT_SECRET=... , or check that the" >&2
|
||||
echo " data directory is writable and that $_jwt_file is not a directory." >&2
|
||||
fi
|
||||
unset _jwt_new _jwt_tmp _jwt_sfx _jwt_made _jwt_try
|
||||
unset _jwt_file _jwt_cur
|
||||
fi
|
||||
|
||||
if [ "$(id -u)" = "0" ]; then
|
||||
if [ -n "$DATA_ROOT_DIR" ] && ! chown nodejs:nodejs "$DATA_ROOT_DIR" 2>/dev/null; then
|
||||
echo "ERROR: failed to chown $DATA_ROOT_DIR to nodejs (UID 1001)." >&2
|
||||
|
||||
@@ -110,9 +110,20 @@ services:
|
||||
- FACE_ML_TOKEN=${FACE_ML_TOKEN:-}
|
||||
- FACE_PROCESSOR_CONCURRENCY=${FACE_PROCESSOR_CONCURRENCY:-}
|
||||
volumes:
|
||||
- ${APP_STORAGE}:/app/storage
|
||||
- ${LOGS}:/app/logs
|
||||
- ${APP_DATA}:/app/data
|
||||
# Defaulted so a .env that simply does not set these still works. Without
|
||||
# them compose aborts with "invalid spec: :/app/storage: empty section
|
||||
# between colons", which reads like a broken compose file rather than a
|
||||
# missing variable (#705).
|
||||
#
|
||||
# Note this does NOT make `config` work with no .env at all: `env_file`
|
||||
# above still requires the file. Making it optional needs
|
||||
# `required: false`, which is Compose 2.24+ syntax that OLDER Compose
|
||||
# rejects as a schema error — taking the whole stack down rather than
|
||||
# just losing a default. Not worth it for a case the onboarding flow
|
||||
# never hits, since it copies .env.example first.
|
||||
- ${APP_STORAGE:-./storage}:/app/storage
|
||||
- ${LOGS:-./logs}:/app/logs
|
||||
- ${APP_DATA:-./data}:/app/data
|
||||
- picpeak-secrets:/run/secrets:ro
|
||||
ports:
|
||||
- "${BACKEND_PORT:-3001}:3000"
|
||||
@@ -182,7 +193,7 @@ services:
|
||||
# Uses same channel as backend for consistency
|
||||
image: ghcr.io/picpeak/picpeak/frontend:${PICPEAK_CHANNEL:-stable}
|
||||
container_name: picpeak-frontend
|
||||
# Note: Pre-built frontend uses Nginx to proxy /api to backend:3001.
|
||||
# Note: Pre-built frontend uses Nginx to proxy /api to backend:3000.
|
||||
# Prefer keeping API base as '/api' in builds to avoid CORS.
|
||||
environment:
|
||||
# Substituted into index.html at container start (see frontend/
|
||||
|
||||
+97
-11
@@ -15,10 +15,16 @@ docker run -d \
|
||||
--name picpeak \
|
||||
-p 3000:3000 \
|
||||
-v picpeak:/data \
|
||||
-e JWT_SECRET="$(openssl rand -base64 48)" \
|
||||
ghcr.io/picpeak/picpeak/aio:stable
|
||||
ghcr.io/picpeak/picpeak/aio:main
|
||||
```
|
||||
|
||||
`:main` is the rolling tag that tracks the default branch. The curated
|
||||
`:stable` and `:latest` tags exist for the backend and frontend images but have
|
||||
not been cut for this one yet, so `:main` is the tag to pull today — pinning a
|
||||
published version tag also works if you would rather not follow the branch —
|
||||
the Releases page, or the package's tag list on the registry, shows what is
|
||||
current.
|
||||
|
||||
Open `http://<host>:3000`. The first visit lands on the setup wizard, which
|
||||
asks for a one-time token:
|
||||
|
||||
@@ -26,10 +32,33 @@ asks for a one-time token:
|
||||
docker exec picpeak cat /data/db/SETUP_TOKEN
|
||||
```
|
||||
|
||||
The token is also printed to the container log on first start.
|
||||
No shell? The file is on the volume you mounted, so any file manager can open
|
||||
it — with `-v picpeak:/data` it is `db/SETUP_TOKEN` inside the volume, and with
|
||||
a host folder it is `<that folder>/db/SETUP_TOKEN`.
|
||||
|
||||
`JWT_SECRET` is the only variable you must set. Generate it once and keep it —
|
||||
changing it invalidates every existing session and gallery link.
|
||||
The token is deliberately **not** written to the container log. It is a live
|
||||
credential for creating the first admin, and logging it would leave it sitting
|
||||
in `combined.log` and `security.log` on the mounted volume long after setup.
|
||||
The log line names the file instead. (If the file could not be written at all,
|
||||
the log carries the token as a last-resort recovery path — that is the only
|
||||
case where it appears there.)
|
||||
|
||||
## Secrets
|
||||
|
||||
Nothing to set. On first start the container generates a `JWT_SECRET` and
|
||||
stores it at `db/jwt.secret` (mode 0600) on the volume, then reuses it on every
|
||||
subsequent boot — so a deployment with no shell, like a NAS Container Manager
|
||||
form, needs no preparation.
|
||||
|
||||
Back it up along with the rest of the volume: losing that file signs every
|
||||
admin session and gallery link out, exactly as changing the secret would.
|
||||
|
||||
Note the **built-in backup does not include it** — that covers the database and
|
||||
the storage tree, not the rest of `/data` — so a restore from `/data/backup`
|
||||
alone will not bring the secret back. Copy the volume.
|
||||
|
||||
Passing `-e JWT_SECRET=…` still overrides it, which is what you want for
|
||||
config-as-code deployments or when several instances must share sessions.
|
||||
|
||||
## What is inside
|
||||
|
||||
@@ -71,32 +100,89 @@ 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.
|
||||
|
||||
### Large libraries and slow start-ups
|
||||
|
||||
The container starts as root just long enough to take ownership of `/data`
|
||||
(UID 1001), then drops privileges. That step walks the tree, so on a big
|
||||
library over a slow filesystem it can add noticeable time to **every** restart,
|
||||
not only the first.
|
||||
|
||||
If that becomes annoying, take ownership once yourself and run as that user —
|
||||
the adoption step is then skipped entirely:
|
||||
|
||||
```bash
|
||||
chown -R 1001:1001 /volume1/docker/picpeak # once, on the host
|
||||
docker run -d --name picpeak -p 3000:3000 \
|
||||
--user 1001:1001 \
|
||||
-v /volume1/docker/picpeak:/data \
|
||||
ghcr.io/picpeak/picpeak/aio:main
|
||||
```
|
||||
|
||||
The container then verifies the directories are writable and fails with a clear
|
||||
message if they are not, rather than trying to fix ownership itself. Note this
|
||||
also means files you drop into the storage tree from outside must already be
|
||||
readable by UID 1001 — relevant if you use a watched folder to ingest photos.
|
||||
|
||||
## Environment
|
||||
|
||||
Only `JWT_SECRET` is required. Everything else has a working default.
|
||||
Nothing is required. Everything below has a working default.
|
||||
|
||||
| Variable | Default | Notes |
|
||||
|---|---|---|
|
||||
| `JWT_SECRET` | — | **Required.** Long random string. |
|
||||
| `JWT_SECRET` | generated | Generated into `db/jwt.secret` on first start and reused after. Set it explicitly to pin it. |
|
||||
| `PORT` | `3000` | Listen port inside the container. |
|
||||
| `FRONTEND_URL` | — | Optional override for the public URL. Normally you set this in the setup wizard instead (it proposes the address you opened), and it is editable later under Settings → General. Setting it here pins the value and makes that field read-only. |
|
||||
| `SMTP_*` | — | Optional override for outbound email, which is normally configured in the setup wizard / Settings → Email. Without either, PicPeak runs fine but sends nothing. |
|
||||
| `DATABASE_CLIENT` | `sqlite3` | Set to `pg` to use an external PostgreSQL. Required — the image declares `sqlite3`, and the boot resolver treats a declared client as an explicit instruction, so `DB_*` alone will **not** switch engines. |
|
||||
| `DB_HOST`, `DB_USER`, `DB_PASSWORD`, `DB_NAME` | — | Connection details, used when `DATABASE_CLIENT=pg`. |
|
||||
| `EXTERNAL_MEDIA_ROOT` | `/external-media` | Read-only photo library to offer in the picker. Mount a folder there and it works without setting this. |
|
||||
|
||||
### 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
|
||||
ghcr.io/picpeak/picpeak/aio:main
|
||||
```
|
||||
|
||||
The image waits for the database to accept connections before running
|
||||
migrations, exactly as the compose backend does.
|
||||
|
||||
## Using photos that are already on the disk
|
||||
|
||||
Galleries do not have to be built from uploads. Mount an existing photo library
|
||||
read-only at `/external-media` and it appears in the admin photo picker:
|
||||
|
||||
```bash
|
||||
docker run -d --name picpeak -p 3000:3000 \
|
||||
-v picpeak:/data \
|
||||
-v /volume1/photo/2026-weddings:/external-media:ro \
|
||||
ghcr.io/picpeak/picpeak/aio:main
|
||||
```
|
||||
|
||||
No environment variable is needed — the path is the default. `EXTERNAL_MEDIA_ROOT`
|
||||
overrides it if you would rather mount somewhere else.
|
||||
|
||||
The location is resolved once, on first use, and cached for the life of the
|
||||
process — so add the mount when you create the container, or restart it
|
||||
afterwards. It will not appear in a running one.
|
||||
|
||||
`:ro` is not a precaution, it is accurate: PicPeak only reads from this tree.
|
||||
Thumbnails are written into the managed storage on the data volume, so the
|
||||
originals are never touched, renamed, or moved.
|
||||
|
||||
Two things to get right:
|
||||
|
||||
- **Mount it outside `/data`.** The data volume is adopted at boot (`chown` to
|
||||
UID 1001) so the app can write to it. A read-only mount nested inside it makes
|
||||
that fail and the container will not start. `/external-media` is its own path
|
||||
for exactly this reason.
|
||||
- **A network share is fine here, and only here.** Because this tree is only
|
||||
read and never adopted, an SMB/CIFS or NFS mount works — which is what makes
|
||||
"the photos already live on the NAS" practical. The same share mounted under
|
||||
`/data` would hang or fail the ownership step instead.
|
||||
|
||||
## TLS
|
||||
|
||||
None is included. Terminate TLS in front of it — your NAS's reverse proxy,
|
||||
@@ -107,9 +193,9 @@ Settings → General (or re-run the setup wizard) 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
|
||||
this from the registry UI: pull `ghcr.io/picpeak/picpeak/aio:main`, map a
|
||||
host port to container port `3000`, and add one volume mapping to `/data`.
|
||||
Set `JWT_SECRET` under Environment.
|
||||
No environment variables are needed — the secret is generated on first start.
|
||||
|
||||
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
|
||||
|
||||
@@ -40,15 +40,15 @@ const config: VitestUserConfig = {
|
||||
host: true,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:7101',
|
||||
target: 'http://localhost:3001',
|
||||
changeOrigin: true,
|
||||
},
|
||||
'/photos': {
|
||||
target: 'http://localhost:7101',
|
||||
target: 'http://localhost:3001',
|
||||
changeOrigin: true,
|
||||
},
|
||||
'/uploads': {
|
||||
target: 'http://localhost:7101',
|
||||
target: 'http://localhost:3001',
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
|
||||
@@ -1,6 +1,30 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
# DEPRECATED — do not use. Superseded by scripts/picpeak-setup.sh.
|
||||
#
|
||||
# This script predates the current deployment layout and no longer works. It
|
||||
# refers to two files that do not exist (docker-compose.prod.yml and
|
||||
# scripts/setup-ssl.sh), and its `sed` calls now match the COMMENTED lines in
|
||||
# the current .env.example, so they produce `#JWT_SECRET=<random>` — still
|
||||
# commented, so no secret is ever set. It fails silently rather than loudly,
|
||||
# which is the worst outcome for an installer.
|
||||
#
|
||||
# Nothing in the repository references it. It is kept as a stub only so an old
|
||||
# bookmark or copied command gets a signpost instead of a broken install; the
|
||||
# body below is unreachable and can be deleted outright whenever convenient.
|
||||
cat >&2 <<'DEPRECATED'
|
||||
scripts/install.sh is deprecated and does nothing.
|
||||
|
||||
Use the current installer instead:
|
||||
|
||||
./scripts/picpeak-setup.sh
|
||||
|
||||
Or run the stack directly — see the Quick Start in README.md, or
|
||||
docs/single-container.md for the single-container image.
|
||||
DEPRECATED
|
||||
exit 1
|
||||
|
||||
echo "Photo Sharing Platform - Docker Installation"
|
||||
echo "==========================================="
|
||||
|
||||
|
||||
+128
-13
@@ -317,6 +317,55 @@ generate_password() {
|
||||
openssl rand -base64 32 | tr -d "=+/" | cut -c1-16
|
||||
}
|
||||
|
||||
# Read one KEY=value out of an existing .env, ignoring commented lines. Prints
|
||||
# the empty string when the file or the key is absent, so callers can fall back
|
||||
# to generating a fresh value with `[[ -n "$x" ]] || x=$(generate...)`.
|
||||
# Emit `KEY=value`, or a commented placeholder when the value is empty — an
|
||||
# empty `KEY=` is NOT equivalent: wait-for-db.sh only falls back to
|
||||
# /run/secrets when the variable is unset or empty, but compose would still
|
||||
# define it, and any future reader that treats "defined" as "configured" gets
|
||||
# the wrong answer. Commented out matches what .env.example ships.
|
||||
env_secret_line() {
|
||||
local key="$1" value="$2"
|
||||
if [[ -n "$value" ]]; then
|
||||
printf '%s=%s' "$key" "$value"
|
||||
else
|
||||
printf '# %s is auto-generated into the picpeak-secrets volume on first run.\n#%s=' "$key" "$key"
|
||||
fi
|
||||
}
|
||||
|
||||
read_env_value() {
|
||||
local file="$1" key="$2"
|
||||
[[ -f "$file" ]] || return 0
|
||||
# An unreadable file is NOT the same as a missing key: returning empty here
|
||||
# would regenerate the secrets and then overwrite the file that held them.
|
||||
# Say so loudly instead of silently rotating.
|
||||
if [[ ! -r "$file" ]]; then
|
||||
log_warn "Cannot read $file — existing secrets cannot be reused. Fix its permissions and re-run; the install will stop rather than overwrite it."
|
||||
return 0
|
||||
fi
|
||||
# Tolerant of the shapes a hand-edited .env actually takes — leading
|
||||
# whitespace, an `export` prefix, spaces around the `=`. Reading one of
|
||||
# those as "absent" would regenerate the secrets and then overwrite the
|
||||
# evidence, which is the exact failure this helper exists to prevent.
|
||||
# Never fails: an unreadable file must not abort the installer under
|
||||
# `set -e` mid-run.
|
||||
# Trailing whitespace and a CR are stripped too. A .env edited on Windows
|
||||
# yields `abc\r`; docker compose treats the CR as a line terminator, so the
|
||||
# running stack uses `abc`, but re-emitting `abc\r` mid-line into a fresh
|
||||
# LF file makes it part of the value. The secret then silently differs from
|
||||
# the one in use — every session dies for JWT_SECRET, and Postgres refuses
|
||||
# the connection for DB_PASSWORD. That is the exact outcome this reuse
|
||||
# exists to prevent, so it must not be reachable through it.
|
||||
# Matching surrounding quotes are stripped too. Compose strips them on read,
|
||||
# so `KEY="abc"` round-trips harmlessly there, but the native path is read by
|
||||
# dotenv into the process env — re-emitting `KEY=\"abc\"` would make the
|
||||
# quotes part of the secret on one path and not the other.
|
||||
sed -n -E "s/^[[:space:]]*(export[[:space:]]+)?${key}[[:space:]]*=[[:space:]]*//p" \
|
||||
"$file" 2>/dev/null | head -n1 \
|
||||
| sed -E -e 's/[[:space:]]+$//' -e 's/^"(.*)"$/\1/' -e "s/^'(.*)'\$/\1/" || true
|
||||
}
|
||||
|
||||
generate_jwt_secret() {
|
||||
openssl rand -base64 64 | tr -d "\n"
|
||||
}
|
||||
@@ -589,16 +638,66 @@ setup_docker_installation() {
|
||||
fi
|
||||
|
||||
# Generate machine secrets. Written to .env so they are stable across
|
||||
# restarts; once PR #714's secrets-init service is present it reuses these
|
||||
# exact values (explicit env always wins), so this stays correct either way.
|
||||
local jwt_secret=$(generate_jwt_secret)
|
||||
local db_password=$(generate_password)
|
||||
local redis_password=$(generate_password)
|
||||
# restarts; the compose secrets-init service reuses these exact values
|
||||
# (explicit env always wins), so this stays correct either way.
|
||||
#
|
||||
# Reuse whatever an existing .env already holds. The file is rewritten
|
||||
# wholesale below, so generating unconditionally would rotate the secrets of
|
||||
# an install that is already running: a new JWT_SECRET invalidates every
|
||||
# admin session and every gallery link, and a new DB_PASSWORD no longer
|
||||
# matches the password baked into the initialised Postgres volume, so the
|
||||
# stack stops booting entirely. `--update` has always been the safe path,
|
||||
# but re-running the installer is an easy mistake to make and it should not
|
||||
# cost the user their data access.
|
||||
local jwt_secret db_password redis_password
|
||||
jwt_secret=$(read_env_value "$app_dir/.env" JWT_SECRET)
|
||||
db_password=$(read_env_value "$app_dir/.env" DB_PASSWORD)
|
||||
redis_password=$(read_env_value "$app_dir/.env" REDIS_PASSWORD)
|
||||
|
||||
# Reading nothing back does NOT mean "no secrets exist". The documented
|
||||
# install leaves all three commented out (.env.example) and lets the
|
||||
# compose `secrets-init` service generate them into the picpeak-secrets
|
||||
# volume, which is then the only place the real values live. Writing freshly
|
||||
# generated ones into .env for that install is the worst possible outcome:
|
||||
# secrets-init keeps the OLD values (it never overwrites), Postgres is still
|
||||
# running on the old password, but explicit env now wins in the backend — so
|
||||
# it can no longer authenticate, and JWT_SECRET rotates every session and
|
||||
# gallery link out. Exactly what this reuse exists to prevent.
|
||||
#
|
||||
# So blank stays blank whenever the volume is there to own it. Only a truly
|
||||
# fresh install — no .env value and no volume — gets generated secrets.
|
||||
local secrets_volume_exists="no"
|
||||
if command_exists docker && docker volume ls --format '{{.Name}}' 2>/dev/null \
|
||||
| grep -qE '(^|_)picpeak-secrets$'; then
|
||||
secrets_volume_exists="yes"
|
||||
fi
|
||||
|
||||
if [[ "$secrets_volume_exists" == "yes" ]]; then
|
||||
[[ -n "$jwt_secret" ]] || log_info "JWT_SECRET is managed by the picpeak-secrets volume — leaving it unset."
|
||||
[[ -n "$db_password" ]] || log_info "DB_PASSWORD is managed by the picpeak-secrets volume — leaving it unset."
|
||||
[[ -n "$redis_password" ]] || log_info "REDIS_PASSWORD is managed by the picpeak-secrets volume — leaving it unset."
|
||||
else
|
||||
[[ -n "$jwt_secret" ]] || jwt_secret=$(generate_jwt_secret)
|
||||
[[ -n "$db_password" ]] || db_password=$(generate_password)
|
||||
[[ -n "$redis_password" ]] || redis_password=$(generate_password)
|
||||
fi
|
||||
|
||||
local frontend_port="${CUSTOM_PORT:-3000}"
|
||||
local site_url; site_url="$(base_url)"
|
||||
|
||||
# Create .env for docker-compose.production.yml (prebuilt GHCR images).
|
||||
# The write is wholesale, so anything else the operator hand-edited (extra
|
||||
# PICPEAK_* flags, S3 settings, TZ) is replaced, not merged — reusing the
|
||||
# three secrets above does not make that safe on its own. Keep a copy first,
|
||||
# the same way update_docker_installation already does.
|
||||
if [ -f "$app_dir/.env" ]; then
|
||||
# The rewrite below is wholesale, so losing this copy loses every other
|
||||
# hand-edit in the file. Fail loudly rather than proceeding without it.
|
||||
if ! cp "$app_dir/.env" "$app_dir/.env.backup-$(date +%Y%m%d-%H%M%S)-$$"; then
|
||||
log_error "Could not back up $app_dir/.env before rewriting it — aborting rather than overwriting it."
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
log_step "Creating configuration..."
|
||||
cat > "$app_dir/.env" <<EOF
|
||||
# PicPeak Configuration — generated by picpeak-setup.sh on $(date)
|
||||
@@ -611,10 +710,12 @@ COMPOSE_FILE=docker-compose.production.yml
|
||||
PICPEAK_CHANNEL=$PICPEAK_CHANNEL
|
||||
NODE_ENV=production
|
||||
|
||||
# Machine secrets (generated; reused by the compose secrets-init service).
|
||||
JWT_SECRET=$jwt_secret
|
||||
DB_PASSWORD=$db_password
|
||||
REDIS_PASSWORD=$redis_password
|
||||
# Machine secrets. Written explicitly only when this file already pinned them or
|
||||
# there is no picpeak-secrets volume to own them; otherwise left commented so the
|
||||
# secrets-init service stays the single source of truth (see .env.example).
|
||||
$(env_secret_line JWT_SECRET "$jwt_secret")
|
||||
$(env_secret_line DB_PASSWORD "$db_password")
|
||||
$(env_secret_line REDIS_PASSWORD "$redis_password")
|
||||
|
||||
# Database
|
||||
DB_HOST=postgres
|
||||
@@ -837,11 +938,25 @@ setup_native_installation() {
|
||||
log_warn "Frontend directory not found; admin UI will not be served by backend"
|
||||
fi
|
||||
|
||||
# Generate secrets
|
||||
local jwt_secret=$(generate_jwt_secret)
|
||||
|
||||
# Generate secrets — reusing an existing one, for the same reason as the
|
||||
# Docker path above: this heredoc rewrites .env wholesale, so generating
|
||||
# unconditionally would rotate JWT_SECRET on a re-run and sign out every
|
||||
# admin session and gallery link on a live install.
|
||||
local jwt_secret
|
||||
jwt_secret=$(read_env_value "$NATIVE_APP_DIR/app/backend/.env" JWT_SECRET)
|
||||
[[ -n "$jwt_secret" ]] || jwt_secret=$(generate_jwt_secret)
|
||||
|
||||
# Create .env file
|
||||
log_step "Creating configuration..."
|
||||
if [ -f "$NATIVE_APP_DIR/app/backend/.env" ]; then
|
||||
# The rewrite below is wholesale, so losing this copy loses every other
|
||||
# hand-edit in the file. Fail loudly rather than proceeding without it.
|
||||
if ! cp "$NATIVE_APP_DIR/app/backend/.env" \
|
||||
"$NATIVE_APP_DIR/app/backend/.env.backup-$(date +%Y%m%d-%H%M%S)-$$"; then
|
||||
log_error "Could not back up $NATIVE_APP_DIR/app/backend/.env before rewriting it — aborting rather than overwriting it."
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
cat > "$NATIVE_APP_DIR/app/backend/.env" <<EOF
|
||||
# PicPeak Native Configuration
|
||||
# Generated: $(date)
|
||||
@@ -1280,7 +1395,7 @@ update_native_installation() {
|
||||
|
||||
# Backup current configuration
|
||||
if [[ -f "$NATIVE_APP_DIR/app/backend/.env" ]]; then
|
||||
cp "$NATIVE_APP_DIR/app/backend/.env" "$NATIVE_APP_DIR/app/backend/.env.backup-$(date +%Y%m%d-%H%M%S)"
|
||||
cp "$NATIVE_APP_DIR/app/backend/.env" "$NATIVE_APP_DIR/app/backend/.env.backup-$(date +%Y%m%d-%H%M%S)-$$"
|
||||
fi
|
||||
|
||||
# Pull latest code
|
||||
|
||||
Reference in New Issue
Block a user