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/the-luap/picpeak/backend3001Express API
frontendghcr.io/the-luap/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.

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