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:
Luca
2026-08-22 19:37:12 +03:00
committed by GitHub
parent 8f23118782
commit 7223118b89
9 changed files with 475 additions and 45 deletions
+57
View File
@@ -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
View File
@@ -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
+4 -3
View File
@@ -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.
+127
View File
@@ -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
+15 -4
View File
@@ -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
View File
@@ -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
+3 -3
View File
@@ -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,
},
},
+24
View File
@@ -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
View File
@@ -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