Files
picpeak/docs/single-container.md
T
Paul Nothaft 167eeaa271 fix(docker): address external review of the all-in-one image (#1042)
Seven findings, all verified against the code before applying.

- The built-in backup system wrote nowhere. Migrations 029/030 seed
  /backup/picpeak and /backup/database as destinations, and this image neither
  created nor mounted /backup — so backups failed, and anything written there
  would have died with the container. /backup is now a symlink into
  /data/backup, so the seeded defaults work and the archives land on the volume
  like everything else.
- Database backups need the sqlite3 CLI. DatabaseBackupService spawns it for
  .backup and PRAGMA integrity_check; the npm module does not provide the
  binary. The compose image omits it because it always runs Postgres — this one
  defaults to SQLite, so it failed with ENOENT. Added.
- Every AIO install served literal ${BRAND_TITLE}. index.html carries
  placeholders that frontend/docker-entrypoint.sh substitutes at start, and
  this image runs no nginx and never invoked it. Rendered at build with the
  same defaults that entrypoint applies, and the build now fails if any
  ${BRAND_*} token survives. Browser testing missed this because the SPA
  rewrites document.title at runtime — the og:/twitter: cards and view-source
  still showed the raw token.
- The SPA fallback swallowed backend file-route 404s. nginx gives /api,
  /photos, /thumbnails and /health their own location blocks, so try_files
  never applies to them; excluding only /api/ made the fallback strictly
  broader than the behaviour it claimed parity with, turning a missing photo
  into a 200 HTML body under an image URL. Now excludes the same set.
- Docs claimed DB_* alone switches the engine. It does not: the image declares
  DATABASE_CLIENT=sqlite3 and the resolver treats a declared client as
  explicit, so DATABASE_CLIENT=pg is required. Corrected, and /data/backup
  added to the documented layout.
- The AIO Trivy upload reused the backend's SARIF category, so the two scans
  replaced each other's results instead of both being retained.
- The AIO build reused the backend's buildx cache scope, so two concurrent jobs
  wrote the same cache object from different Dockerfiles.

Verified on a rebuilt image: sqlite3 3.53.2 present, <title>PicPeak</title>,
/backup -> /data/backup with both seeded subdirectories, /photos + /thumbnails
+ /api back to 404 while /setup /impressum /gallery/x /admin/login stay 200.
2026-08-16 23:11:51 +02:00

5.3 KiB

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 instead. This image is the small end of the range, not a replacement for it.

Run it

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:

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
/data/backup built-in backup output (/backup is symlinked here)

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.
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.

Using an external PostgreSQL

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.

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.