Docker
PicPeak provides two Docker Compose files:
docker-compose.yml--- builds from source; includes an optional MailHog mail catcher for email testing, gated behind thedevprofile (start it withdocker compose --profile dev up -d).docker-compose.production.yml--- uses pre-built images from GitHub Container Registry (the same images are also on Docker Hub, see below). Use this one for production, including for updates: run compose commands with-f docker-compose.production.yml.
Services
| Service | Image | Port | Description |
|---|---|---|---|
backend | ghcr.io/picpeak/picpeak/backend | 3001 (host loopback only) | Express API |
frontend | ghcr.io/picpeak/picpeak/frontend | 3000 | React SPA + Nginx |
postgres | postgres:15-alpine | not published | PostgreSQL database |
redis | redis:7-alpine | not published | Session and cache store |
Docker Hub mirror
Every PicPeak image is published to Docker Hub as well, with the same tags:
| GHCR | Docker Hub |
|---|---|
ghcr.io/picpeak/picpeak/backend | picpeak/backend |
ghcr.io/picpeak/picpeak/frontend | picpeak/frontend |
ghcr.io/picpeak/picpeak/aio | picpeak/aio |
ghcr.io/picpeak/picpeak/ml | picpeak/ml |
Use Docker Hub when your platform only searches there — NAS container apps (Synology Container Manager, QNAP Container Station, UGREEN Docker) do by default. To switch a compose file, replace the ghcr.io/picpeak/picpeak/ prefix with picpeak/.
Supported architectures
The pre-built images on GHCR and Docker Hub ship as multi-arch manifests covering linux/amd64 and linux/arm64. Docker pulls the variant matching your host automatically — no platform: flags or compose edits needed.
| Host | Native variant |
|---|---|
| x86_64 Linux servers (most VPS / cloud hosts) | linux/amd64 |
| Apple Silicon Macs (M1 / M2 / M3 / M4) | linux/arm64 |
| Raspberry Pi 4/5, AWS Graviton, Ampere, ARM64 Linux | linux/arm64 |
postgres:15-alpine and redis:7-alpine are already multi-arch upstream, so the entire stack runs natively on every supported host.
If you’re on a transitional image tag from before multi-arch was published (cached :stable / :latest from earlier releases), an docker-compose.amd64.override.yml ships in the repo as an opt-in fallback. Load it with -f docker-compose.amd64.override.yml to force linux/amd64 on backend and frontend until your next docker compose pull. ARM64 Linux hosts should never load this override — forcing emulation on a native host is the wrong choice.
Production deployment
docker compose -f docker-compose.production.yml up -dNetwork defaults
| Setting | Default | Change it when |
|---|---|---|
Frontend port (FRONTEND_PORT, 3000) | published on all interfaces (PICPEAK_BIND_ADDRESS=0.0.0.0) | A TLS reverse proxy runs on the same host: set PICPEAK_BIND_ADDRESS=127.0.0.1 |
Backend port (BACKEND_PORT, 3001) | published on host loopback only (PICPEAK_BACKEND_BIND_ADDRESS=127.0.0.1) | Something on another host must reach the backend directly |
TRUST_PROXY | 1 (the frontend nginx) | Another reverse proxy forwards to the frontend port: set 2 |
COOKIE_SECURE | auto | The site is only reached over HTTPS: set true |
ENABLE_HSTS | false | The site is only reached over HTTPS: set true |
The installer writes the HTTPS values for you in its reverse-proxy mode. See Reverse Proxy and Environment Variables.
Volumes
The production compose file mounts these host directories into the backend container:
| Host path | Container path | Purpose |
|---|---|---|
$APP_STORAGE (default ./storage) | /app/storage | Photos, thumbnails, uploads |
$LOGS (default ./logs) | /app/logs | Application logs |
$APP_DATA (default ./data) | /app/data | Credentials, SQLite fallback |
$APP_BACKUP (default ./backup) | /backup | Built-in local backups and database dumps |
$APP_UPDATE/request (default ./update/request) | /app/update/request | In-app update requests (read-write) |
$APP_UPDATE/status (default ./update/status) | /app/update/status | In-app update status (read-only) |
PostgreSQL and Redis use named Docker volumes (postgres-data, redis-data).
The production compose file mounts ${APP_BACKUP:-./backup} at /backup and sets BACKUP_DIR=/backup, so the entrypoint creates /backup/picpeak and /backup/database and hands them to UID 1001. Keep APP_BACKUP outside APP_STORAGE, or the backup would include itself. This mount is in the compose file from v3.142.1-beta.0; the compose file on the stable branch does not mount /backup yet, so there database dumps land under the backup destination unless you add the mount and BACKUP_DIR yourself. See Where the database dump is written.
The two update mounts are in the compose file from v3.165.0-beta.0. Docker creates a root-owned ./update/ with request/ and status/ on the next docker compose up -d, even while in-app updates stay off; nothing reads them then.
The backup manifest signing key is generated into the picpeak-secrets volume as backup_manifest_key and only the backend reads it. Copy it off the host: without it a backup cannot be authenticated on a new host. See Environment Variables → Backup and restore.
Generated secrets live in the picpeak-secrets volume, which only the backend mounts. In builds containing PicPeak PR #1491 , PostgreSQL and Redis read their passwords from their own volumes, picpeak-db-secret and picpeak-redis-secret, so neither container can read the backend’s JWT signing key. The secrets-init service fills both from picpeak-secrets on every start: upgrading needs no manual step, and the passwords stay the same.
Permissions
As of the post-#484 release, the backend container starts as root, chowns the bind-mounted host directories (/app/storage, /app/data, /app/logs) to UID 1001 (the in-container nodejs user), then drops privileges via su-exec before running the app. Fresh installs need no host-side chown.
The nodejs runtime user is hard-coded — PUID/PGID env vars from older docs are no longer read.
Pinning a specific runtime UID (advanced)
If your environment requires the app to run as a specific host UID (e.g. shared NFS exports where 1001 is taken), pre-chown the host directories and pin user: in your compose override:
services:
backend:
user: "<uid>:<gid>"# On the host, matching the UID you pinned above
chown -R <uid>:<gid> ./storage ./data ./logsWhen user: is set to anything other than root, the entrypoint’s self-chown branch is skipped. A preflight check then verifies the bind mounts are writable; if they aren’t, the container exits immediately with a clear error pointing back to this section — no silent restart loop.
External database
To use an external PostgreSQL instead of the bundled container, set these variables and remove or disable the postgres service:
DB_HOST=db.example.com
DB_PORT=5432
DB_USER=picpeak
DB_PASSWORD=your_password
DB_NAME=picpeak_prodIf DB_HOST is not set, it defaults to postgres (the bundled container).
For a database that requires TLS, set DB_SSL=true. PicPeak then verifies the server certificate and host name, including for backup dumps and restores; a private or self-signed CA needs DB_SSL_CA. See Environment Variables → Database.
Updating
Pre-built images
docker compose -f docker-compose.production.yml pull
docker compose -f docker-compose.production.yml down
docker compose -f docker-compose.production.yml up -d
docker compose -f docker-compose.production.yml psFrom source
git pull
docker compose build --no-cache
docker compose down
docker compose up -dStopping and restarting
In builds containing PicPeak PR #1359 , photo and face workers interrupt idle polling and retry waits immediately on shutdown. Database operations and photo processing already in flight drain within the backend’s shutdown deadline; reaching that deadline forces an exit, so active jobs are not guaranteed to finish. The existing SHUTDOWN_TIMEOUT_MS setting defaults to 8000 (8 seconds). If you increase it, also set Docker Compose’s stop_grace_period longer than the backend deadline so Docker does not kill the container first.
Database migrations
Migrations run automatically on container startup via the backend’s wait-for-db.sh (which calls npm run migrate:safe once Postgres is reachable). Manually triggering them is rarely needed.
Avoid running npm run migrate while the backend is still booting — the in-container migration is already in progress, and a parallel run can leave the schema half-applied. Wait for docker compose ps to report the backend as healthy, then exec into it if you need to inspect migration state.
If you need to inspect migration status post-boot:
docker exec picpeak-backend npm run migrate:safemigrate:safe is idempotent — it skips any migrations already recorded in knex_migrations and prints a summary.
Revocation migration (PR #1359 ): 211_revocations_without_expiry.js makes revoked_tokens.expires_at nullable while preserving existing records. A NULL expiry means permanent revocation. Rolling this migration back is refused while any permanent records exist. Do not delete those records to force a rollback: that could make revoked tokens usable again.
Resource requirements
-
4 GB RAM minimum for normal photo-upload workloads. Sharp/libvips decodes the full uncompressed frame before resize, and the default two worker loops at sharp-concurrency 2 can push peak RSS past 1.5 GB on a batch of 20-MP+ photos. Add Postgres + Redis + Node baseline and one busy batch is enough to OOM-kill the backend on a 2 GB VPS — visible as 503 Service Unavailable on thumbnail requests until
restart: unless-stoppedbrings the container back. -
2 vCPU minimum. Sharp is CPU-bound on decode + resize.
Low-memory hosts
Running on 2 GB RAM (e.g. an entry-level VPS) is workable, but you need to tune the upload-processor concurrency down. The backend auto-detects total RAM at startup via os.totalmem() — on a host that reports under 3 GB, UPLOAD_PROCESSOR_CONCURRENCY defaults to 1 instead of 2 (and prints a one-shot warning naming the override). You can pin the value explicitly in .env:
# Single worker loop — slower batch processing, lower peak RSS
UPLOAD_PROCESSOR_CONCURRENCY=1Throughput trade-off: a 100-photo batch takes roughly twice as long with a single worker but won’t OOM. Persistent 503s on thumbnails immediately after an upload batch on a low-memory host are almost always the backend getting OOM-killed and restarting — docker logs picpeak-backend shows the cycle.
Memory cap (optional, recommended on shared hosts)
docker-compose.production.yml ships a commented mem_limit example on the backend service. Uncomment and tune to your host’s RAM budget. With a cap set, the kernel OOM-killer takes the container (not the whole host) and restart: unless-stopped brings it back — quicker recovery than waiting for the host to swap-thrash itself out.
backend:
# …
mem_limit: 3g
memswap_limit: 3gHealth checks
# Backend (responds with {"status":"ok"}, or 503 {"status":"error"} when the database is unreachable)
curl http://localhost:3001/health
# Frontend (Nginx /health endpoint, returns "healthy")
curl http://localhost:3000/health
# PostgreSQL — pin -d to your DB_NAME so the probe hits the real
# database (otherwise pg_isready defaults to a db named after the
# user and the postgres container logs spurious FATAL messages).
docker exec picpeak-postgres pg_isready -U picpeak -d picpeak_prodThe backend answers the same at /api/health. The answer carries nothing else: the process id and uptime it used to include are shown to admins in the system status instead (beta from v3.175.1-beta.0, stable from v3.134.7). While a .picpeak restore runs it answers 200 with "maintenance": "restoring" (or "restart_required" after a committed restore), so a health-based restarter leaves the process alone.
Upgrading from an older release? If your postgres container logs spam FATAL: database "picpeak" does not exist even though the install works, you’re hitting the pg_isready default-database quirk. Fixed in the production compose file as of the post-#484 release; pull the latest compose with git pull (or re-download docker-compose.production.yml) and restart.
Useful commands
# View logs
docker compose -f docker-compose.production.yml logs -f
docker compose -f docker-compose.production.yml logs -f backend
# Enter backend container
docker exec -it picpeak-backend sh
# Enter database
docker exec -it picpeak-postgres psql -U picpeak picpeak_prod
# Reset admin password
docker exec picpeak-backend node scripts/show-admin-credentials.js --reset
# Check disk usage
du -sh events/ storage/ backup/Troubleshooting fresh installs
These are the symptoms that have surfaced on first docker compose up -d against a clean host. All have been fixed in the post-#484 / #494 / #511 release window — pull the latest images and compose file before troubleshooting further.
Backend in a restart loop, no obvious error in docker logs
Cause: the container couldn’t write to the bind-mounted host directories. Until the post-#484 release the entrypoint silently swallowed the mkdir failure, hit a confusing migration error, and exited — looking like a database problem when it was actually filesystem permissions.
Fix: pull the post-#484 backend image. The new entrypoint chowns the bind mounts itself on first boot and re-execs as nodejs. If you’ve pinned user: in a compose override, see Permissions above for the manual pre-chown step.
relation "photos" does not exist on cold-start Postgres
Cause: the events.hero_photo_id FK was declared inline before the photos table existed in the same init transaction. Postgres rejects this; SQLite silently accepted it, which is why long-lived SQLite installs were unaffected.
Fix: resolved in #494 — column is declared without the inline FK, then the FK is added via ALTER TABLE after both tables exist. Pull the latest backend image, docker compose down -v, up -d.
column "created_at" does not exist in backup_runs index migrations
Cause: migration 035 created two indexes referencing a created_at column, but the underlying table was built by migration 029 with a started_at column. The wrapping try/catch swallowed the error, so migrations reported success while the indexes were never built.
Fix: resolved in #511 — migration 035 now uses started_at, and a new migration 105 backfills the indexes on installs where 035 silently failed. No manual action needed; the backfill runs on next boot.
duplicate key value violates unique constraint "migrations_filename_unique" on 004_add_categories_and_cms.js
Cause: run-migrations-safe.js snapshotted the applied-migrations set before detectExistingSchema() inserted rows for table-mapped legacy migrations, so the loop re-ran a migration whose internal insert into migrations then collided with the row detectExistingSchema had just written.
Fix: resolved in #511 — the applied set is re-queried after the detection step. Only triggered when a previous boot landed in a partial state (typically after one of the earlier-listed bugs aborted mid-init).
Postgres logs spam FATAL: database "picpeak" does not exist
Cause: pg_isready defaults to a database named after the connecting user when -d isn’t passed. The healthcheck was hitting that default DB instead of the real one, and Postgres logged a FATAL on every probe (every 2 seconds) — noisy but not actually breaking anything.
Fix: resolved in #488 / #511 — the production compose file pins pg_isready -d picpeak_prod. Pull the latest docker-compose.production.yml.