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 18:37:12 +02:00
committed by GitHub
parent 8f23118782
commit 7223118b89
9 changed files with 475 additions and 45 deletions
+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