Files
picpeak/docs/single-container.md
T
Paul Nothaft 6769d79e08 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.
2026-08-16 22:03:58 +02:00

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.