Skip to Content
DeploymentDocker Configuration

Docker

PicPeak provides two Docker Compose files:

  • docker-compose.yml --- builds from source, includes MailHog for email testing.
  • docker-compose.production.yml --- uses pre-built images from GitHub Container Registry.

Services

ServiceImagePortDescription
backendghcr.io/picpeak/picpeak/backend3001Express API
frontendghcr.io/picpeak/picpeak/frontend3000React SPA + Nginx
postgrespostgres:15-alpine5432PostgreSQL database
redisredis:7-alpine6379Session and cache store

Production deployment

docker compose -f docker-compose.production.yml up -d

Volumes

The production compose file mounts these host directories into the backend container:

Host pathContainer pathPurpose
$APP_STORAGE (default ./storage)/app/storagePhotos, thumbnails, uploads
$LOGS (default ./logs)/app/logsApplication logs
$APP_DATA (default ./data)/app/dataCredentials, SQLite fallback

PostgreSQL and Redis use named Docker volumes (postgres-data, redis-data).

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

When 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_prod

If DB_HOST is not set, it defaults to postgres (the bundled container).

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 ps

From source

git pull docker compose build --no-cache docker compose down docker compose up -d

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:safe

migrate:safe is idempotent — it skips any migrations already recorded in knex_migrations and prints a summary.

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-stopped brings 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=1

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

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: 3g

Health checks

# Backend (responds with { status: "ok", pid, uptime }) 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_prod

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.

Last updated on