feat(docker): official single-container (all-in-one) image (#1042)
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.
This commit is contained in:
@@ -0,0 +1,142 @@
|
||||
# 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.
|
||||
Reference in New Issue
Block a user