One `docker run`, one volume, working PicPeak — for the #705 audience on a NAS or small VPS, where the four-service compose stack is what loses against SaaS onboarding. Second of the two PRs planned there; #1041/#1043 shipped the upgrade path out of it first, so a small install is never a dead end. One Node process: no supervisor, no nginx, no Postgres, no Redis. SQLite is the explicit, documented default — a library in-process writing one file on the volume, no second daemon, no credentials, no pg_upgrade dance on image bumps. DB_* still points at an external Postgres; the engine is resolved and logged at boot (#1038). Everything under one mount: /data/db (database + SETUP_TOKEN), /data/storage, /data/logs. Four backend changes were needed to make this work, all of which also fix the existing SERVE_FRONTEND path: - wait-for-db.sh waited for PostgreSQL unconditionally, resolving the engine only afterwards. On SQLite that blocks forever on a host that will never answer. Engine resolution moves ahead of the wait and the wait is skipped for sqlite3. resolveBootEngine reads the filesystem and env, never a live connection, so it is safe to run first. - The writable-root checks hard-coded /app/storage, /app/data, /app/logs, so a single /data volume could not be guarded. They now follow the same STORAGE_PATH / DATA_DIR / LOG_DIR the app reads. - express.static sent no Cache-Control at all. nginx sets `immutable` on hashed assets and no-store on index.html; without the latter a stale index.html after an upgrade names chunks that no longer exist and the app won't boot. - The SPA history fallback only listed /admin/* and /gallery/*. nginx does `try_files $uri $uri/ /index.html`, so behind compose every route survived a reload — but direct hits on /setup, /impressum, /quote/:id, /transfer/:id, /invite/:token, /customer, /contract/:id, /payment-check and CMS /:slug all 404'd without it. /setup is the first URL a new install visits. The catch-all is registered after the API 404 handler, so /api/* still answers JSON. Header parity with frontend/nginx.conf was verified against a running container, not assumed: CSP, X-Frame-Options, X-Content-Type-Options, Referrer-Policy and Permissions-Policy all match (helmet already emitted them — nginx proxy_hide_header's its copies to avoid duplicates). CI publishes ghcr.io/.../aio with the same per-arch build -> manifest merge and Trivy-by-digest scanning as the other two images. Verified by building and running the image: SQLite boot healthy in ~6s, full setup wizard completed in a browser, admin dashboard live, data surviving container replacement, and an external-Postgres run healthy against a real Postgres 15.
143 lines
5.0 KiB
Markdown
143 lines
5.0 KiB
Markdown
# PicPeak all-in-one (single container)
|
|
|
|
One `docker run`, one volume, working PicPeak. Built for NAS boxes (Synology,
|
|
UGREEN, QNAP) and small VPSes where standing up a four-service compose stack is
|
|
the thing that makes people give up and go back to a SaaS.
|
|
|
|
If you already run PostgreSQL, or you expect several photographers hitting the
|
|
admin UI at once, use the [compose stack](../README.md) instead. This image is
|
|
the small end of the range, not a replacement for it.
|
|
|
|
## Run it
|
|
|
|
```bash
|
|
docker run -d \
|
|
--name picpeak \
|
|
-p 3000:3000 \
|
|
-v picpeak:/data \
|
|
-e JWT_SECRET="$(openssl rand -base64 48)" \
|
|
ghcr.io/picpeak/picpeak/aio:stable
|
|
```
|
|
|
|
Open `http://<host>:3000`. The first visit lands on the setup wizard, which
|
|
asks for a one-time token:
|
|
|
|
```bash
|
|
docker exec picpeak cat /data/db/SETUP_TOKEN
|
|
```
|
|
|
|
The token is also printed to the container log on first start.
|
|
|
|
`JWT_SECRET` is the only variable you must set. Generate it once and keep it —
|
|
changing it invalidates every existing session and gallery link.
|
|
|
|
## What is inside
|
|
|
|
One Node process. No supervisor, no nginx, no PostgreSQL, no Redis.
|
|
|
|
The backend serves the built frontend directly and applies the same security
|
|
headers the nginx container applies in the compose stack — same CSP, same
|
|
`X-Frame-Options`, `X-Content-Type-Options`, `Referrer-Policy` and
|
|
`Permissions-Policy`, plus the same cache tiers (hashed assets immutable,
|
|
`index.html` never cached).
|
|
|
|
**SQLite is the default**, deliberately. It is a library inside the process
|
|
writing one file on your volume: no second daemon, no credentials, no startup
|
|
ordering, and no `pg_upgrade` dance when you pull a newer image. The engine is
|
|
resolved and logged at boot, so `docker logs` always tells you which database
|
|
you are actually on:
|
|
|
|
```
|
|
Database engine: sqlite (/data/db/picpeak.db)
|
|
```
|
|
|
|
Redis is absent — nothing in the backend needs it at runtime.
|
|
|
|
## The volume
|
|
|
|
Everything that must survive a container replacement lives under `/data`:
|
|
|
|
| Path | Contents |
|
|
|---|---|
|
|
| `/data/db` | `picpeak.db` (+ `-wal`/`-shm`) and `SETUP_TOKEN` |
|
|
| `/data/storage` | originals, thumbnails, archives |
|
|
| `/data/logs` | application logs |
|
|
|
|
One mount point is the whole point. Back up `/data` and you have backed up the
|
|
install.
|
|
|
|
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.
|
|
|
|
## Environment
|
|
|
|
Only `JWT_SECRET` is required. Everything else has a working default.
|
|
|
|
| Variable | Default | Notes |
|
|
|---|---|---|
|
|
| `JWT_SECRET` | — | **Required.** Long random string. |
|
|
| `PORT` | `3000` | Listen port inside the container. |
|
|
| `FRONTEND_URL` | — | Public URL. Set it once you are behind a domain, so emails and share links point at the right host. |
|
|
| `SMTP_*` | — | Outbound email. Without it, PicPeak runs fine but sends nothing. |
|
|
| `DB_HOST`, `DB_USER`, `DB_PASSWORD`, `DB_NAME` | — | Point at an **external** PostgreSQL. Setting these switches the engine off SQLite. |
|
|
|
|
### 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
|
|
```
|
|
|
|
The image waits for the database to accept connections before running
|
|
migrations, exactly as the compose backend does.
|
|
|
|
## TLS
|
|
|
|
None is included. Terminate TLS in front of it — your NAS's reverse proxy,
|
|
Caddy, nginx, or a Cloudflare Tunnel. Set `FRONTEND_URL` to the public
|
|
`https://…` address 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
|
|
host port to container port `3000`, and add one volume mapping to `/data`.
|
|
Set `JWT_SECRET` under Environment.
|
|
|
|
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
|
|
adopt the directory, then drops to UID 1001.
|
|
|
|
## Outgrowing it
|
|
|
|
A single-container install is never a dead end. When you need the full stack:
|
|
|
|
1. **Settings → Backup → Export `.picpeak`** (include photos).
|
|
2. Stand up the compose stack with PostgreSQL and run its setup wizard.
|
|
3. **Settings → Backup → Restore** the `.picpeak` file.
|
|
|
|
SQLite → PostgreSQL restore is supported (#1041); the reverse is not. Your
|
|
galleries, settings, customers and photos come across.
|
|
|
|
## Health
|
|
|
|
`/health` returns 200 when the database is reachable and 503 when it is not,
|
|
and the image's `HEALTHCHECK` uses it — so `docker ps` showing `healthy`
|
|
means the app can actually serve, not merely that a socket is open.
|
|
|
|
```bash
|
|
docker inspect --format='{{.State.Health.Status}}' picpeak
|
|
```
|
|
|
|
## Limits
|
|
|
|
- **SQLite means one writer.** Fine for one photographer plus guests
|
|
browsing; if several admins upload simultaneously all day, move to
|
|
PostgreSQL.
|
|
- **No built-in TLS or reverse proxy.**
|
|
- **No Redis**, so nothing here scales horizontally — run one container.
|