# PicPeak Environment Configuration # Copy this file to .env and update with your values # Environment NODE_ENV=production # JWT Secret — OPTIONAL. Leave unset and it is auto-generated on first run # (Docker: the secrets-init service writes it to a private volume and reuses it # across restarts). Set it explicitly only to pin your own value. # Generate one with: openssl rand -base64 64 #JWT_SECRET=your_very_long_random_jwt_secret_here # Standard backup authentication: keep this dedicated trust anchor separately # from backup media/database dumps. Compose creates it in its private secrets # volume; native/AIO creates DATA_DIR/backup-manifest.key on first backup. # Generate an explicit key with: openssl rand -hex 32 (64 hex digits only). # Retain it off-host for disaster recovery. A lost key NEVER verifies a backup. #BACKUP_MANIFEST_KEY= #BACKUP_MANIFEST_KEY_FILE=/trusted/private/backup-manifest.key # Earlier v3 signing keys (comma-separated 64-hex values), while rotating: #BACKUP_MANIFEST_KEYS_OLD= # Explicit compatibility for old CANONICAL HMAC manifests: original key string, # any length. A pre-upgrade BACKUP_MANIFEST_KEY that is not 64 hex digits keeps # verifying what it signed, but signing stays disabled until it is replaced. # Unsafe legacy serializers and unkeyed backups still require audited recovery. #BACKUP_MANIFEST_LEGACY_KEY= # One-artifact UNAUTHENTICATED recovery, only after offline inspection: # run backend/scripts/backup-manifest-recovery-digest.js, set its full digest # plus a meaningful operator reason, restore on an isolated host, then REMOVE # both variables. Normal force/boot-trigger contents cannot enable this. #BACKUP_MANIFEST_RECOVERY_SHA256= #BACKUP_MANIFEST_RECOVERY_REASON= # Encrypts invitation, password-reset, and temporary-password email variables # while they wait in the database. Defaults to JWT_SECRET. Keep it stable while # recovery mail is pending; a rotated/lost key makes those rows fail closed. # Generate with: openssl rand -hex 32 #EMAIL_QUEUE_ENCRYPTION_KEY= # Allow only these exact private analytics/OIDC origins (scheme + host + port). # Needed for internal Umami/Rybbit/identity providers; metadata stays blocked. #INTEGRATION_PRIVATE_ORIGINS=http://umami:3000,https://idp.internal # How long a gallery guest stays recognised (#1210). Default 30d. It was 24h, # which meant a client reviewing a gallery across two weekends registered again # in between — and each re-registration is a separate guest whose likes and # favourites no longer join up with the first visit's. Takes any jsonwebtoken # duration ('7d', '12h'); shorten it if your galleries hold sensitive work. #GUEST_TOKEN_TTL=30d # OIDC SSO for admins (#798) — configured in the admin UI; only these two # values live in the environment: # Key encrypting the OIDC client secret at rest (defaults to JWT_SECRET). #OIDC_ENCRYPTION_KEY= # Break-glass: 'true' re-enables local password login even while the SSO # settings disable it (recovery when the IdP is down or misconfigured). #OIDC_BREAK_GLASS=false # Signing evidence key (#1446) — encrypts every contract signer's name, email # address, IP address, user agent and decline reason at rest. LOSING IT MAKES # THAT EVIDENCE UNREADABLE; the signatures, the PDFs and the audit chain stay # valid, but signer names and addresses come back empty. # # Accepted forms: 64 hex digits, 43 characters of base64 (32 bytes), or a # passphrase (run through scrypt). A value that is a character or two off one # of the two key forms is refused at boot rather than silently derived. # openssl rand -hex 32 # Use a raw 32-byte key in production. A passphrase is derived with a fixed # salt, so the evidence is only as strong as the passphrase; the server logs # a warning at boot while one is configured. # # Leave it unset and PicPeak generates a key on first use and stores it at # /business-docs/keys/evidence.key (mode 0600). That path is inside # the backup export, so a restore brings the key with it — but it is then the # ONLY copy, so back it up together with the database. The server logs a # warning at boot while the key comes from that file. #PICPEAK_EVIDENCE_KEY= # # Rotating the evidence key: set PICPEAK_EVIDENCE_KEY to the new key, list the # previous key(s) here (comma-separated, same accepted forms) so existing # evidence stays readable, restart, then run # docker compose exec backend node scripts/rotate-evidence-key.js # Keep the old keys until the script reports no values left under them. A # generated key file needs no entry here: the script keeps it as # business-docs/keys/evidence.key., which is read automatically. #PICPEAK_EVIDENCE_KEYS_OLD= # Auth cookie Secure flag # unset - default: auto in production, false in development # true - always set Secure (HTTPS-only cookies; breaks plain-HTTP access) # false - never set Secure (allows HTTP; cookies not protected on HTTPS) # auto - decide per request: Secure on HTTPS, not on HTTP # Also accepted: 1/yes/on (true) and 0/no/off (false). Any other value is # treated as auto and logged once at startup. # # Production defaults to auto so a plain-HTTP install (LAN, NAS, no reverse # proxy yet) can still log in. Recommended once the site is only ever reached # over HTTPS: COOKIE_SECURE=true. Auto reads req.secure, which only respects # X-Forwarded-Proto when the sender is inside the TRUST_PROXY boundary. # # Requirements for auto mode: # 1. Your reverse proxy MUST send X-Forwarded-Proto: https on HTTPS # requests. Standard configs for NPM/Traefik/Caddy do this by default. # 2. The proxy must be trusted (see TRUST_PROXY below). Compose defaults # TRUST_PROXY to 1 (its frontend nginx); behind another proxy set 2. # COOKIE_SECURE=auto # Cookie SameSite attribute (Lax | Strict | None). Default: Lax # COOKIE_SAMESITE=Lax # Cookie Domain — set this if serving auth cookies across subdomains. # Leave unset for same-origin setups. # COOKIE_DOMAIN=.example.com # Database Configuration (PostgreSQL) DATABASE_CLIENT=pg DB_USER=picpeak # DB_PASSWORD — OPTIONAL. Leave unset and it is auto-generated on first run # (Docker). Set it explicitly to pin your own, e.g. for an external database. # IMPORTANT: Avoid $ character in passwords - Docker Compose interprets it as variable substitution # If you must use $, escape it as $$ (e.g., Pass$$word instead of Pass$word) #DB_PASSWORD=your_secure_postgres_password_here DB_NAME=picpeak_prod # PostgreSQL TLS (external databases). true verifies certificates + hostname # across the app, startup, backups and restores. The bundled database has no # TLS, so leave false for the default compose stack. #DB_SSL=false # Private/self-signed CA: PEM text or a read-only mounted container file path. #DB_SSL_CA=/run/certs/postgres-ca.pem # Insecure opt-out: false accepts ANY certificate. Prefer setting DB_SSL_CA. #DB_SSL_REJECT_UNAUTHORIZED=true # Certificate name when it is not DB_HOST (DB_HOST is an IP or a compose alias). # The app verifies this name. psql/pg_dump (startup, backups, restores) verify # it too when DB_HOST is an IP; for a DNS DB_HOST they verify the CA chain only # (sslmode=verify-ca), so pair it with DB_SSL_CA. #DB_SSL_SERVERNAME=db.example.com # Redis Configuration # REDIS_PASSWORD — OPTIONAL. Leave unset and it is auto-generated on first run (Docker). # IMPORTANT: Same warning applies - avoid $ or escape as $$ #REDIS_PASSWORD=your_secure_redis_password_here # Admin Account (initial setup) — OPTIONAL # Leave these unset (default) to create your admin IN THE BROWSER on first run: # open /admin and PicPeak shows a setup screen. The one-time setup token is # written to data/SETUP_TOKEN with mode 0600 — read it with # `docker compose exec backend cat /app/data/SETUP_TOKEN`. It is NOT logged # unless that write fails, so it never sits in `docker logs`. # The all-in-one image keeps it at /data/db/SETUP_TOKEN — inside the volume, # in the db/ subdirectory (#1218). On a NAS with no shell, set ADMIN_PASSWORD # below instead: it needs no file at all. # Set ADMIN_PASSWORD to auto-create the admin on first boot instead (legacy; # credentials written to data/ADMIN_CREDENTIALS.txt). #ADMIN_USERNAME=admin #ADMIN_EMAIL=admin@yourdomain.com #ADMIN_PASSWORD=your_secure_admin_password_here # Email Configuration — OPTIONAL, and normally left alone. # SMTP is configured in the setup wizard / Settings -> Email and stored in the # database (email_configs); that is what the mail queue actually sends with. # These variables are a legacy path kept for config-as-code deployments: when # SMTP_HOST is set, the initial migration seeds the database row from it. # Developers running the `dev` compose profile want SMTP_HOST=mailhog here so # that seed points at the mailhog container. # Set SMTP_PORT=1025 with it. The SMTP_HOST/SMTP_PORT pair set here is trusted # as a private mail server for exactly that host and port (port 1025 when # SMTP_PORT is unset). Any other private mail server (a host or port changed in # the admin UI, an IMAP server) fails closed until it is listed below. # Approvals are deployment-only protocol/host/port tuples, comma-separated, and # may point at private, loopback or carrier-grade NAT (Tailscale) addresses; # metadata, link-local and reserved destinations remain forbidden even if listed. # An invalid entry is ignored with one error in the log. #MAIL_PRIVATE_ENDPOINTS=smtp://mailhog:1025,imap://mail.internal:993 # For Gmail: use app-specific password # For SendGrid: SMTP_USER=apikey, SMTP_PASS=your-api-key #SMTP_HOST=smtp.gmail.com #SMTP_PORT=587 #SMTP_SECURE=false #SMTP_USER=your-email@gmail.com #SMTP_PASS=your-app-specific-password #EMAIL_FROM=noreply@yourdomain.com # Webhook email transport (#1225) — OPTIONAL, an alternative to SMTP entirely. # When EMAIL_WEBHOOK_URL is set, PicPeak stops sending mail itself and POSTs # each composed message as JSON to that URL instead; something downstream # (n8n, Make, a self-hosted relay) delivers it. Useful when SMTP is the part # you cannot get working — app passwords, blocked ports, a NAS with no # outbound 25. # # Deliberately environment-only, not an admin setting: it redirects every # outbound message including password resets, so it should not be changeable # from a compromised admin session. # # EMAIL_WEBHOOK_SECRET is REQUIRED. The body is signed with it and sent as # X-PicPeak-Signature (HMAC-SHA256, hex) — the same scheme as gallery # webhooks, so a receiver verifies both the same way. Set the URL without a # secret and the transport stays OFF and says so in the log, rather than # posting unauthenticated mail to the internet. # # Payload: { from, to[], cc[], subject, html, text, attachments[] }, where each # attachment is { filename, content_type, content_base64 }. Attachments are # included rather than dropped; a message whose attachments exceed 10 MB fails # and stays in the queue instead of arriving without its invoice. # # The receiver must be a public https:// address unless you opt in — a container or LAN # address is refused by the SSRF check otherwise. Running n8n beside PicPeak is # normal, so set EMAIL_WEBHOOK_ALLOW_PRIVATE_URLS=true for that. # # Webhook and email-webhook deliveries connect to the DNS answer they just # validated and ignore HTTP_PROXY / HTTPS_PROXY. Behind a mandatory egress # proxy set the *_ALLOW_PRIVATE_URLS flag, which sends through the proxy # without pinning. # # A mail account with its own SMTP host (Settings -> Mail accounts) keeps # sending through it; this replaces the global transport only. # # Set EMAIL_FROM above as well. A webhook-only install never gets an # email_configs row (that is seeded only when SMTP_HOST is set), so EMAIL_FROM # is where the sender address comes from. #EMAIL_WEBHOOK_URL=https://n8n.example.com/webhook/picpeak-mail #EMAIL_WEBHOOK_SECRET=generate-a-long-random-string #EMAIL_WEBHOOK_ALLOW_PRIVATE_URLS=false # Application URLs — OPTIONAL. Leave unset for the normal install. # The public origin is captured by the setup wizard (it proposes the address # you opened the browser at) and stored as the `general_site_url` setting, so # you can change it later in Settings -> General without touching this file. # Setting FRONTEND_URL here OVERRIDES that setting and makes the field # read-only in the admin UI - use it only for config-as-code deployments. # Use full origin with scheme, no trailing slash. # Admin UI is served by the frontend at /admin. #FRONTEND_URL=https://yourdomain.com #ADMIN_URL=https://yourdomain.com # Static HTML title + description used for social link previews when the # fetcher doesn't trigger the per-event OG endpoint — most notably the # WhatsApp Business API and various 3rd-party preview-service caches # (#521). Set these to your brand so link previews aren't generic. # Substituted into index.html at frontend-container start, so changes # take effect on the next `docker compose up -d frontend` — no rebuild # required. BRAND_TITLE=PicPeak BRAND_DESCRIPTION=Photo gallery shared with PicPeak. # API URL for email assets (logos, images in notification emails) # OPTIONAL: when unset this is derived from the resolved public origin + /api, # so the wizard's answer covers it. Set it only for split-origin deployments # where the API lives on a different host than the gallery. #API_URL=https://yourdomain.com/api # Frontend API base # For pre-built images and production behind a reverse proxy, keep '/api'. # If you rebuild the frontend yourself, you may set a full URL at build time. VITE_API_URL=/api # Network and port configuration (optional) # Compose publishes the frontend on every host interface by default # (0.0.0.0). Recommended when a TLS reverse proxy on this host fronts PicPeak: # PICPEAK_BIND_ADDRESS=127.0.0.1, so the plain-HTTP port is not reachable # around the proxy. # PICPEAK_BIND_ADDRESS=0.0.0.0 # The raw backend port is published on host loopback only; the frontend # reaches the backend over the Compose network. Set 0.0.0.0 only when # something on another host must reach the backend port directly. # PICPEAK_BACKEND_BIND_ADDRESS=127.0.0.1 # BACKEND_PORT=3001 # FRONTEND_PORT=3000 # DB_PORT=5432 # REDIS_PORT=6379 # # Address the Node process binds. Unset binds every interface. Recommended # for a native install whose reverse proxy runs on the same host: # LISTEN_HOST=127.0.0.1 # # Express trust-proxy setting. Unset in the backend's own environment (a # native install, or an older compose file that does not set it) keeps the # legacy behaviour for compatibility: forwarding headers are trusted from loopback and every # private-range address, and a warning is logged at startup. Recommended: set # the exact number of reverse-proxy hops in front of the backend. false (or # 0) trusts no forwarding headers. # Accepts true, a hop count, Express subnet names, CIDRs, or comma lists. # Never use true unless every path to the app crosses a controlled proxy. # Compose defaults to 1 (frontend nginx): a client-sent X-Forwarded-For cannot # change the rate-limit key. Behind another reverse proxy in front of the # frontend (Cloudflare Tunnel, Traefik, NPM, Caddy) set 2, or every visitor # shares the proxy's address. A host-local TLS proxy in front of # the frontend needs 2 (and PICPEAK_BIND_ADDRESS=127.0.0.1); the installer # sets both in proxy mode. # A hop count is safe only when clients cannot take a shorter path to the app. # TRUST_PROXY=loopback # The all-in-one image (no compose file) defaults to 0: no proxy inside it. # Behind a reverse proxy pass -e TRUST_PROXY=1, one per proxy hop. # ENABLE_HSTS=false # File watcher (watch-folder auto-import, local storage only) # Max photos processed in parallel — raise on hosts with memory headroom, # lower to 1 on very small hosts. Default: 2 # FILE_WATCHER_CONCURRENCY=2 # How long a new file must stop growing before it is imported, and how often # that is checked, in milliseconds. Raise the quiet window on a slow NAS or a # camera tether that flushes in bursts. Defaults: 2000 and 100 # FILE_WATCHER_STABILITY_MS=2000 # FILE_WATCHER_POLL_INTERVAL_MS=100 # External-media folder watcher (issue 1187). Reference-mode events can opt in # per event (Event → Source Mode → "Watch folder for new files"); new images in # the folder are then imported without pressing Import. Deleted files are # never removed from the gallery. # EXTERNAL_MEDIA_WATCH=true # global kill switch # EXTERNAL_MEDIA_WATCH_POLLING=false # true = stat-polling instead of inotify (NFS/SMB mounts) # EXTERNAL_MEDIA_WATCH_POLL_INTERVAL_MS=5000 # EXTERNAL_MEDIA_WATCH_SWEEP_INTERVAL_MS=900000 # timer-driven pass over every watched event; 0 disables # EXTERNAL_MEDIA_WATCH_DEBOUNCE_MS=10000 # quiet period after the last change before the import runs # EXTERNAL_MEDIA_WATCH_STABILITY_MS=5000 # how long a file must stop growing before it counts as written # Release Channel # Options: 'stable' (default), 'beta', or specific version like 'v2.3.0' # 'stable' uses the :stable tag (same as :latest on main) # 'beta' uses the :beta tag for pre-release versions PICPEAK_CHANNEL=stable # Update Check Configuration # Set to 'false' to disable update notifications in admin UI UPDATE_CHECK_ENABLED=true # In-app updates (optional, off by default) — see docs/self-update.md. # Script installs: `picpeak-setup.sh --enable-self-update` sets this up. # Container variant (hosts without a shell): uncomment the first three. # PICPEAK_SELF_UPDATE=true # COMPOSE_PROFILES=updater # PICPEAK_PROJECT_DIR=/absolute/path/to/this/directory # APP_UPDATE=./update # request/status files shared with the updater # Timezone TZ=UTC # Storage variables (host paths) # These control where data is stored on the host. Defaults are local folders. APP_STORAGE=./storage APP_DATA=./data LOGS=./logs # Host directory for the built-in backups (mounted at /backup). Keep it outside # APP_STORAGE, or the backup would include itself. APP_BACKUP=./backup # ─── Storage Backend ──────────────────────────────────────────────────────── # PicPeak can store photos, thumbnails and archive zips on the local filesystem # (default) or on any S3-compatible object store (AWS S3, MinIO, Cloudflare R2, # Backblaze B2, Wasabi, DigitalOcean Spaces, …). # # STORAGE_BACKEND=local (default) # Uses STORAGE_PATH on the local filesystem. Backwards compatible — every # existing deployment keeps working unchanged. # # STORAGE_BACKEND=s3 # Reads STORAGE_S3_* below. Auto-import via the filesystem watcher is # disabled in this mode (S3 has no inotify) — every photo must enter via the # admin upload UI/API. Run `node backend/scripts/migrate-storage.js` to copy # existing local content to S3 before flipping the env. # # STORAGE_BACKEND=local # # STORAGE_S3_BUCKET=picpeak # STORAGE_S3_REGION=us-east-1 # STORAGE_S3_ACCESS_KEY=AKIAxxxxxxxxxxxxxxxx # STORAGE_S3_SECRET_KEY=xxxxxxxxxxxxxxxxxxxxxxxx # Custom endpoint — set this for MinIO / R2 / B2 / Spaces. Leave unset for AWS. # STORAGE_S3_ENDPOINT=https://s3.us-west-002.backblazeb2.com # Optional namespace prefix inside the bucket — useful for multi-deployment buckets. # STORAGE_S3_PREFIX=picpeak # STORAGE_S3_FORCE_PATH_STYLE=false # MinIO needs true; auto-on when endpoint is set # STORAGE_S3_SSL=true # Largest video (bytes) a lazy thumbnail rebuild will download from S3 for a # poster frame; bigger ones get the placeholder. Default: 512 MB # VIDEO_THUMBNAIL_MAX_SOURCE_BYTES=536870912 # # Minimum IAM policy (AWS S3) for the bucket above: # { # "Version": "2012-10-17", # "Statement": [{ # "Effect": "Allow", # "Action": [ # "s3:GetObject", "s3:PutObject", "s3:DeleteObject", # "s3:ListBucket", "s3:GetBucketLocation" # ], # "Resource": [ # "arn:aws:s3:::picpeak", # "arn:aws:s3:::picpeak/*" # ] # }] # } # # EXTERNAL_MEDIA_ROOT (above) always lives on the local filesystem regardless # of STORAGE_BACKEND — reference-mode galleries are not migrated to S3 in v1. # ─── Outbound Webhooks (#327) ──────────────────────────────────────────────── # PicPeak POSTs event/photo lifecycle notifications to URLs you configure # under Settings → Webhooks. Each delivery is signed HMAC-SHA256 with a # per-webhook secret in the X-PicPeak-Signature header. # # WEBHOOK_ALLOW_PRIVATE_URLS (default: false) # Block URLs resolving to private IPs / loopback / .local etc. as an # SSRF mitigation. Set to "true" ONLY in dev when your receiver is on # the same docker network or localhost. Production deployments must # leave this OFF. # WEBHOOK_ALLOW_PRIVATE_URLS=false # # WEBHOOK_DELIVERY_INTERVAL_MS (default: 5000) # How often the worker polls webhook_deliveries for pending rows. # WEBHOOK_DELIVERY_INTERVAL_MS=5000 # # WEBHOOK_DELIVERY_CONCURRENCY (default: 5) # Maximum in-flight deliveries per worker tick. One slow consumer can # monopolize all 5 slots — bump this if your receivers are slow OR ship # a separate webhook-only deployment. # WEBHOOK_DELIVERY_CONCURRENCY=5 # # WEBHOOK_HTTP_TIMEOUT_MS (default: 10000) # Per-request timeout. Beyond this, the delivery is recorded as a # network error and retried. # WEBHOOK_HTTP_TIMEOUT_MS=10000 # # WEBHOOK_MAX_ATTEMPTS (default: 5) # Total attempts before a delivery is marked failed. Backoff between # attempts is exponential: 1m, 5m, 30m, 2h, 12h. # WEBHOOK_MAX_ATTEMPTS=5 # ----------------------------------------------------------------------------- # Face recognition — "People in this gallery" (#1074, optional) # ----------------------------------------------------------------------------- # Requires the optional picpeak-ml sidecar container: # docker compose --profile faces up -d # # NONE of these variables do anything until the `faces` feature flag is # enabled in Admin → Settings, AND the per-event "Detect people in this # gallery" toggle is switched on. Both default to OFF. With the flag off the # backend never contacts the sidecar, so leaving these at their defaults on an # install without the container is completely inert. # # Face embeddings are biometric data (GDPR Art. 9 special category in the EU). # The photographer is the controller and needs a lawful basis for the people # in their photos — read https://docs.picpeak.app/features/face-recognition # before enabling. # # NOT AVAILABLE ON THE ALL-IN-ONE IMAGE. The single-container build sets # PICPEAK_SINGLE_CONTAINER=true and the backend refuses to enable face # recognition there regardless of these variables or the feature flag: that # image runs the backend, frontend, database and every worker in one # container, with no ML sidecar to talk to, and face detection would compete # with image processing for the same CPU and memory. Use the standard # multi-container deployment if you want this feature. # # FACE_ML_TOKEN (no default — REQUIRED to run the sidecar) # Shared secret between the backend and the sidecar. The sidecar refuses to # start without it rather than serving anonymously, so an accidentally # published port is never a free face-detection API. Generate with: # openssl rand -hex 32 # FACE_ML_TOKEN= # # FACE_ML_URL (default: http://picpeak-ml:8000) # Defaults to the sidecar's compose service name, so the standard # deployment needs no configuration here. Only change it if you run the # sidecar outside the default compose network. # FACE_ML_URL=http://picpeak-ml:8000 # # FACE_PROCESSOR_CONCURRENCY (default: 1) # Face-detection workers in the backend. Defaults to 1 deliberately: face # scanning shares a host with Sharp image processing, which is the real # memory pressure (see UPLOAD_PROCESSOR_CONCURRENCY). Raise only on hosts # with headroom to spare. # FACE_PROCESSOR_CONCURRENCY=1 # # FACE_ORT_THREADS (default: 1) # ONNX Runtime threads inside the sidecar. More threads mean faster # per-photo inference and higher RSS. # FACE_ORT_THREADS=1 # ----------------------------------------------------------------------------- # Automatic scene tags (issue 1733, optional) # ----------------------------------------------------------------------------- # Uses the same picpeak-ml sidecar, FACE_ML_URL and FACE_ML_TOKEN as above, # but is switched on separately: the `autoTagging` feature flag in Admin → # Settings AND the per-gallery switch. Both default to OFF. Scene tags # ("cake", "dancing", "outdoor") are not biometric data. # # The published sidecar image does not include the tagging model yet. Build it # yourself with the two build arguments below (see ml/README.md); without # them tagged galleries simply stay queued. # TAG_ENCODER_ONNX_URL= # TAG_ENCODER_ONNX_SHA256= # # TAG_PROCESSOR_CONCURRENCY (default: 1) # Tagging workers in the backend. # TAG_PROCESSOR_CONCURRENCY=1 # ========================================================================== # Malware scanner for customer documents (issue 1444) — optional # ========================================================================== # With CLAMAV_HOST set, every document a customer (or admin) uploads in the # customer portal is sent to clamd before it is stored: a clean verdict makes # it available at once, a detection refuses it. Without a scanner, uploads # stay "awaiting review" until an admin marks them clean. A scan that fails # (timeout, clamd down) leaves the file pending; an hourly re-scan retries. # The admin review stays available either way: an admin may mark a pending # file clean by hand, scanner or not. Only a file the scanner flagged as # malware can't be marked clean. # # The bundled compose file has an opt-in ClamAV service: # docker compose --profile clamav up -d and set CLAMAV_HOST=clamav # Plan for about 1 GB of RAM for it. # # CLAMAV_HOST (no default — unset means no scanner) # CLAMAV_HOST=clamav # # CLAMAV_PORT (default: 3310) # CLAMAV_PORT=3310 # # CLAMAV_TIMEOUT_MS (default: 30000) — connect plus the whole scan # CLAMAV_TIMEOUT_MS=30000 # # CLAMAV_MAX_BYTES (default: 26214400 = 25 MiB, clamd's StreamMaxLength) # Larger files are not sent and stay pending. Keep it at least as large as # the upload limit (Settings → CRM → Customer documents), and raise clamd's # StreamMaxLength to match if you raise it. # CLAMAV_MAX_BYTES=26214400 # Note on FRONTEND_API_URL (documentation only): # When using pre-built frontend images, runtime env vars cannot override the built JS. # Do NOT rely on FRONTEND_API_URL in Compose. Instead, keep VITE_API_URL=/api and # let the frontend Nginx proxy /api to the backend. Only if you rebuild the frontend # should you change VITE_API_URL at build time. # Optional product usage (#1110): disabled until explicit in-app consent. # USAGE_COLLECTOR_URL=https://usage.picpeak.app # Backend signing-key encryption (32+ characters); defaults to JWT_SECRET. # Keep this value stable until participation has been deleted. # USAGE_ENCRYPTION_KEY= # Graceful shutdown budget in milliseconds. On SIGTERM the server stops # accepting requests, drains workers and closes the pool; whatever is still # running after this long is abandoned so the process exits before Docker's # 10 s stop grace period (raise stop_grace_period together with this value). #SHUTDOWN_TIMEOUT_MS=8000 ## Limits on PUBLIC uploads only (gallery guests, PicTransfer file requests); ## photographer/admin/API/import uploads never count. Optional JSON overrides, ## see docs/PUBLIC_UPLOAD_LIMITS.md. Empty = defaults: ## request body 95 MiB (never less than one file of the configured maximum) ## gallery 50 GiB / 20000 files lifetime, 16 concurrent, 20 GiB per hour ## guest 50 GiB / 20000 files lifetime, 16 concurrent, 20 GiB per hour (same as gallery) ## transfer 50 GiB / 20000 files lifetime, 4 concurrent, 20 GiB per hour ## account 500 GiB / 200000 files lifetime, 32 concurrent, 100 GiB per hour ## deployment unlimited lifetime (free-disk headroom), 64 concurrent ## e.g. {"gallery":{"bytes":107374182400},"guest":{"requests":8}} PUBLIC_UPLOAD_LIMITS_JSON= ## Authenticated uploads (admin UI, API token, resumable, PicTransfer files) ## are never rate-limited or charged; only what is in flight is bounded. ## Optional JSON overrides, same document. Empty = defaults: ## stagedBytes min(50 GiB, 25% of free space) held in staging at once ## stagedFiles 50000 ## accountRequests 16 concurrent per uploading admin account ## requests 64 concurrent per deployment ## requestTimeoutMs 600000 (10 minutes per multipart request or chunk) ## headroomBytes 512 MiB, headroomPercent 0 (off), headroomFiles 1024 ## e.g. {"stagedBytes":214748364800,"accountRequests":32} ADMIN_UPLOAD_LIMITS_JSON= # Image parsing and transforms run in a small pool of warm worker processes, # outside the backend. On Linux each worker has a hard address-space limit; # where that limit cannot be applied (macOS/Windows development, a system that # refuses `ulimit -v`) the worker runs without it, and if no worker can be # started at all sharp runs inside the backend as it did before. The backend # logs one warning at startup saying which protection is off and why. The # pixel limit applies in every mode. # Defaults accept what earlier versions accepted: 268402689 pixels (sharp's # own limit) and 65535 px per side. No frame, decoded-size or file-size limit # applies unless one is set below. An image over a limit is refused at upload # with a message naming the limit. # Worker memory defaults to a quarter of the host/container memory (768 MiB to # 4 GiB) with at most two workers inside half of that memory, and never fewer # than one. A job that runs out of memory in a shared worker is run once more # on its own with half of the host/container memory, unless # IMAGE_WORKER_MEMORY_MIB is set. # Queued photos wait for a free worker; they are not refused. Only on-demand # thumbnail/preview requests are answered 503 (Retry-After) once more than # IMAGE_WORKER_QUEUE_LENGTH jobs are waiting. The timeout counts from the # moment a job starts, not while it waits. A photo whose processing was # refused for a transient reason goes back into the queue (5 attempts). # All overrides must be positive integers. # IMAGE_WORKER_MEMORY_MIB= # IMAGE_MAX_PIXELS=268402689 # IMAGE_MAX_DIMENSION=65535 # IMAGE_MAX_FRAMES= # IMAGE_MAX_DECODED_MIB= # IMAGE_MAX_INPUT_MIB= # IMAGE_WORKER_QUEUE_LENGTH=256 # IMAGE_WORKER_TIMEOUT_MS=120000 # ffmpeg, ffprobe (videos) and exiftool (RAW previews): two OPTIONAL # protections, see docs/MEDIA_PROCESSING.md. The backend logs one line at # startup saying which are active and, for each that is not, why. # process guard memory/CPU/thread limits per tool. Needs Linux, the compiled # guard and ptrace for its own child (Docker's default allows # it). Without it the tools run unguarded, with the same # timeouts (SIGTERM, then SIGKILL) and thread caps. # kernel leases a crashed worker's photo is recovered as soon as its lock is # free. Needs Linux, the compiled addon and a lease directory # on a LOCAL filesystem. Without them it is recovered by age. # Neither is required: macOS/Windows development, Docker Desktop, storage on # NFS/CIFS and installs without a C compiler all process media normally. # Docker images build both; a native Linux install can with # `cd backend && npm run build:native` (C compiler + Node headers). # # Host identity for crash recovery: this value, else /etc/machine-id, else an # id generated once in backend/data/media-host-id. Set it only when several # backend processes on ONE host do not share that data directory; 16-128 # characters of A-Z a-z 0-9 . _ - and different on every host. # MEDIA_PROCESS_HOST_ID= # Lease directory. Default: /picpeak-media-leases, then # backend/data/media-process-leases. Must be on local disk (ext4, xfs, btrfs, # zfs, tmpfs, overlay); a refused directory falls back to the defaults. # MEDIA_PROCESS_LEASE_PATH= # # Queued photos and videos wait; they are not refused for capacity. Probes, # posters and RAW previews run in one lane, transcodes in another, and each # lane always runs at least one job. A job's time budget starts when the job # starts. "Not now" (shutdown, busy, a tool that did not start) puts the photo # back in the queue (5 attempts) instead of failing it. # MEDIA_PROCESS_CONCURRENCY=2 # MEDIA_PROCESS_QUEUE_LENGTH=256 # Address-space limit under the guard for ffprobe, exiftool, a poster frame: # MEDIA_WORKER_MEMORY_MIB=2048 # ... and for a transcode. Unset = no limit: a multi-threaded encode reserves # far more virtual memory than it uses and no default fits every source. # MEDIA_FFMPEG_MEMORY_MIB= # ffmpeg threads. Default: the CPU count, at most 4. # MEDIA_FFMPEG_THREADS= # MEDIA_PROBE_TIMEOUT_MS=30000 # MEDIA_RAW_TIMEOUT_MS=30000 # MEDIA_THUMBNAIL_TIMEOUT_MS=60000 # A transcode gets at least VIDEO_RENDITION_TIMEOUT_MS; a longer video gets # 20 s per second of video, up to VIDEO_RENDITION_MAX_TIMEOUT_MS. # VIDEO_RENDITION_TIMEOUT_MS=3600000 # VIDEO_RENDITION_MAX_TIMEOUT_MS=86400000 # Adaptive video streaming (off until switched on under Settings > General). # Its transcodes share the transcode lane and the time budget above; a video # beyond these limits is left as it is and keeps playing from the original. # VIDEO_HLS_CONCURRENCY=1 # VIDEO_HLS_MAX_DURATION_SECONDS=1800 # VIDEO_HLS_MAX_SOURCE_MIB=4096 # Private input copies staged at once (at most half the memory); a larger # file is read where it is. # MEDIA_MAX_SNAPSHOT_MIB=1024 # MEDIA_MAX_VIDEO_PIXELS=40000000 # MEDIA_MAX_VIDEO_DIMENSION=16384 # MEDIA_MAX_VIDEO_STREAMS=16 # MEDIA_MAX_VIDEO_FPS=240 # No limit unless set, as before these existed. A video over one keeps its # original and gets the placeholder tile with a note. # MEDIA_MAX_INPUT_MIB= # MEDIA_MAX_VIDEO_OUTPUT_MIB= # MEDIA_MAX_VIDEO_DURATION_SECONDS= # MEDIA_MAX_VIDEO_PIXEL_FRAMES= # Portable restore (.picpeak import) is OPTIONAL: it needs Linux, the native # build and STORAGE_PATH on a local filesystem. Where the host does not qualify # the application runs normally and only the import is switched off (one # startup log line says why). See docs/PORTABLE_RESTORE.md. # Defaults accept what earlier versions accepted; each may be raised or lowered. # PICPEAK_IMPORT_MAX_ENTRIES=2000000 # PICPEAK_IMPORT_MAX_EXPANDED_BYTES=1099511627776 # PICPEAK_IMPORT_MAX_MANIFEST_BYTES=268435456 # PICPEAK_IMPORT_MAX_ROW_BYTES=67108864 # How long running requests may finish before a restore closes their # connections. If work still does not end the restore is aborted. # PICPEAK_RESTORE_DRAIN_GRACE_MS=60000 # The restore worker's budgets follow the archive size; set these only to # override that. No CPU-time limit unless one is set. # PICPEAK_IMPORT_WORKER_MEMORY_MIB= # PICPEAK_IMPORT_WORKER_TIMEOUT_MS= # PICPEAK_IMPORT_WORKER_CPU_SECONDS= # Analytics collection is data-only. HTTPS is mandatory in production. # Explicit opt-in for an HTTP collector on a non-production backend only: ANALYTICS_ALLOW_INSECURE_HTTP=false