The workers couldn't decrypt users.nextcloud_app_password_enc because SECRET_KEY wasn't in their env. _credentials_for() then raised NextcloudCredentialsMissing and our code swallowed it as "no NC auth → fall back to local path." Surfaced on the Phase 4 deploy when /data/thumbs/.../medium.webp was purged and the vision worker had no disk fallback left. NC preview fetch then returned None, the classifier got no image, and the photo failed to classify. Also masked Phase 3 silently — extract_metadata in worker-light was falling back to ExifTool every time instead of hitting Memories (which would have been fine because ExifTool produces the same fields, but slower and unnecessary). With SECRET_KEY available, the Memories primary path actually fires.
343 lines
15 KiB
YAML
343 lines
15 KiB
YAML
services:
|
||
frontend:
|
||
build:
|
||
context: ./frontend
|
||
dockerfile: Dockerfile
|
||
container_name: mulita-frontend
|
||
ports:
|
||
# Host port is configurable via FRONTEND_PORT in .env so multiple
|
||
# instances / other services on the same host don't collide.
|
||
- "${FRONTEND_PORT:-3000}:80"
|
||
depends_on:
|
||
- backend
|
||
networks:
|
||
- mulita-network
|
||
# Pin cloud.hubris.network to the LAN caddy IP. Without this, the
|
||
# docker DNS forwards the lookup to the host's resolver, which
|
||
# returns the public IONOS VPS IP — but cloud isn't in the VPS
|
||
# traefik exposure list, so TLS handshakes against it die with
|
||
# "unexpected eof while reading". Caddy on 192.168.8.175 holds the
|
||
# cloud.hubris.network cert and proxies to the Nextcloud LXC.
|
||
extra_hosts:
|
||
- "cloud.hubris.network:192.168.8.175"
|
||
restart: unless-stopped
|
||
|
||
backend:
|
||
build:
|
||
context: ./backend
|
||
dockerfile: Dockerfile
|
||
container_name: mulita-backend
|
||
ports:
|
||
# Direct backend access on the host is rarely needed (the frontend
|
||
# talks to it through the nginx /api proxy on the same network),
|
||
# but it's exposed for debugging / curl. Override with BACKEND_PORT.
|
||
- "${BACKEND_PORT:-8001}:8000"
|
||
volumes:
|
||
- ./mulita.yml:/app/config/mulita.yml:ro
|
||
# The single host → container mount for your photo library. Set
|
||
# PHOTO_DIRS in .env to your library root. Mounted :rw because file
|
||
# operations (rename, move, empty discard pile) need to mutate the
|
||
# filesystem; flip to :ro for a strict read-only library and the
|
||
# write endpoints will return EROFS.
|
||
- ${PHOTO_DIRS:-./photos}:/photos:rw
|
||
# Optional Nextcloud integration: mount the homecloud data dir so
|
||
# users can register subfolders of their Nextcloud `files/` tree
|
||
# as per-user SourceRoots. Reads use this path directly; mutations
|
||
# (upload, delete, rename, move) dispatch via WebDAV against
|
||
# NEXTCLOUD_BASE_URL so Nextcloud's oc_filecache stays in sync.
|
||
# Leave NEXTCLOUD_USERS_HOST_PATH unset (or pointing at a no-op
|
||
# path) to disable.
|
||
- ${NEXTCLOUD_USERS_HOST_PATH:-./photos}:/nextcloud-users:rw
|
||
- thumbs_data:/data/thumbs
|
||
- proxies_data:/data/proxies
|
||
- db_data:/data/db # retained so the docker-compose.sqlite.yml override has somewhere to put mulita.db
|
||
# Run Alembic migrations before starting uvicorn. On a fresh Postgres
|
||
# the empty 0001 baseline is a no-op stamp; create_all in init_db then
|
||
# builds the schema.
|
||
# init_db creates all tables from models (idempotent create_all),
|
||
# then Alembic runs migrations for existing installs. On fresh DBs
|
||
# create_all already built the full schema, so bootstrap.py stamps
|
||
# alembic head to skip redundant ALTER statements.
|
||
command: sh -c "python -c 'import asyncio; from app.database import init_db; asyncio.run(init_db())' && python bootstrap.py && uvicorn app.main:app --host 0.0.0.0 --port 8000 --workers 2 --proxy-headers"
|
||
environment:
|
||
- DATABASE_URL=postgresql+asyncpg://mulita:mulita@db:5432/mulita
|
||
- REDIS_URL=redis://redis:6379
|
||
- CELERY_BROKER_URL=redis://redis:6379
|
||
- CELERY_RESULT_BACKEND=redis://redis:6379
|
||
- PHOTO_DIRS=/photos
|
||
- ALLOWED_ORIGINS=${ALLOWED_ORIGINS:-*}
|
||
- SECRET_KEY=${SECRET_KEY:-mulita-dev-secret-change-me}
|
||
- ACCESS_TOKEN_EXPIRE_MINUTES=${ACCESS_TOKEN_EXPIRE_MINUTES:-60}
|
||
- REFRESH_TOKEN_EXPIRE_DAYS=${REFRESH_TOKEN_EXPIRE_DAYS:-30}
|
||
# Authentik / OIDC single sign-on. Leave OIDC_ENABLED=false to
|
||
# hide the SSO button and stick with username/password. When
|
||
# enabled, set OIDC_ISSUER to the Authentik provider URL (the one
|
||
# that serves /.well-known/openid-configuration), and paste the
|
||
# client id/secret from the Authentik application. OIDC_REDIRECT_URI
|
||
# must match the one registered on the Authentik side exactly —
|
||
# e.g. https://photovault.example.com/api/v1/auth/oidc/callback.
|
||
- OIDC_ENABLED=${OIDC_ENABLED:-false}
|
||
- OIDC_ISSUER=${OIDC_ISSUER:-}
|
||
- OIDC_CLIENT_ID=${OIDC_CLIENT_ID:-}
|
||
- OIDC_CLIENT_SECRET=${OIDC_CLIENT_SECRET:-}
|
||
- OIDC_REDIRECT_URI=${OIDC_REDIRECT_URI:-}
|
||
- OIDC_SCOPES=${OIDC_SCOPES:-openid profile email}
|
||
- OIDC_PROVIDER_LABEL=${OIDC_PROVIDER_LABEL:-Authentik}
|
||
- OIDC_ALLOW_SIGNUP=${OIDC_ALLOW_SIGNUP:-true}
|
||
- OIDC_ADMIN_GROUPS=${OIDC_ADMIN_GROUPS:-}
|
||
- OIDC_LINK_BY_USERNAME=${OIDC_LINK_BY_USERNAME:-false}
|
||
- SESSION_SECRET=${SESSION_SECRET:-}
|
||
# Nextcloud integration. NEXTCLOUD_USERS_ROOT is the in-container
|
||
# path that NEXTCLOUD_USERS_HOST_PATH binds to. NEXTCLOUD_BASE_URL
|
||
# is the public-facing Nextcloud URL used for outgoing WebDAV
|
||
# calls (must be reachable from the backend container; e.g.
|
||
# https://cloud.example.com or http://nextcloud:80 if you put it
|
||
# on the same docker network). Leave NEXTCLOUD_BASE_URL unset to
|
||
# keep the integration off — the router endpoints stay registered
|
||
# but mutating endpoints fail with a clear error.
|
||
- NEXTCLOUD_USERS_ROOT=${NEXTCLOUD_USERS_ROOT:-/nextcloud-users}
|
||
- NEXTCLOUD_BASE_URL=${NEXTCLOUD_BASE_URL:-}
|
||
- NEXTCLOUD_WEBHOOK_SECRET=${NEXTCLOUD_WEBHOOK_SECRET:-}
|
||
- SECRET_KEY=${SECRET_KEY:-mulita-dev-secret-change-me}
|
||
- LOG_LEVEL=${LOG_LEVEL:-INFO}
|
||
- TZ=${TZ:-UTC}
|
||
depends_on:
|
||
redis:
|
||
condition: service_started
|
||
db:
|
||
condition: service_healthy
|
||
networks:
|
||
- mulita-network
|
||
# Pin cloud.hubris.network to the LAN caddy IP. Without this, the
|
||
# docker DNS forwards the lookup to the host's resolver, which
|
||
# returns the public IONOS VPS IP — but cloud isn't in the VPS
|
||
# traefik exposure list, so TLS handshakes against it die with
|
||
# "unexpected eof while reading". Caddy on 192.168.8.175 holds the
|
||
# cloud.hubris.network cert and proxies to the Nextcloud LXC.
|
||
extra_hosts:
|
||
- "cloud.hubris.network:192.168.8.175"
|
||
restart: unless-stopped
|
||
|
||
# ── Celery workers ─────────────────────────────────────────────────────
|
||
#
|
||
# The ingestion pipeline is split across two worker services so CPU-heavy
|
||
# vision tasks (embed / detect / OCR / faces / classify) cannot starve
|
||
# the fast IO-bound tasks (scan / thumbnails / EXIF / phash / duplicates).
|
||
#
|
||
# worker-light listens on default,high,low — IO-bound, cheap
|
||
# worker-vision listens on vision — CPU-bound, loads ONNX
|
||
#
|
||
# Both share the same image, photo volume, and model cache, so there's
|
||
# no disk duplication and model weights are loaded lazily only by
|
||
# worker-vision. Each service has its own concurrency knob; both
|
||
# workers ship their heartbeat to the same Redis broker so the
|
||
# Settings > Workers panel lists them side-by-side.
|
||
#
|
||
# Sizing defaults target a 6-core / 16 GB host:
|
||
# CELERY_LIGHT_CONCURRENCY=2 (enough for parallel thumbnail + EXIF)
|
||
# CELERY_VISION_CONCURRENCY=5 (5 × ~2GB ONNX = ~10GB RAM, 5/6 cores)
|
||
# Raise these in .env and run `docker compose up -d worker-light worker-vision`
|
||
# to scale. Keep light under ~4 and vision under your physical core
|
||
# count; more just thrashes.
|
||
worker-light:
|
||
build:
|
||
context: ./backend
|
||
dockerfile: Dockerfile
|
||
image: mule-image-worker
|
||
container_name: mulita-worker-light
|
||
command: sh -c "python -m app.services.vision.bootstrap_models && celery -A app.tasks.celery worker --beat --loglevel=${LOG_LEVEL:-info} --concurrency=${CELERY_LIGHT_CONCURRENCY:-2} -Q default,high,low -n light@%h"
|
||
volumes:
|
||
- ./mulita.yml:/app/config/mulita.yml:ro
|
||
- ${PHOTO_DIRS:-./photos}:/photos:rw
|
||
- ${NEXTCLOUD_USERS_HOST_PATH:-./photos}:/nextcloud-users:rw
|
||
- thumbs_data:/data/thumbs
|
||
- proxies_data:/data/proxies
|
||
- db_data:/data/db
|
||
- models_data:/data/models
|
||
environment:
|
||
- DATABASE_URL=postgresql+asyncpg://mulita:mulita@db:5432/mulita
|
||
- REDIS_URL=redis://redis:6379
|
||
- CELERY_BROKER_URL=redis://redis:6379
|
||
- CELERY_RESULT_BACKEND=redis://redis:6379
|
||
- PHOTO_DIRS=/photos
|
||
- NEXTCLOUD_USERS_ROOT=${NEXTCLOUD_USERS_ROOT:-/nextcloud-users}
|
||
- NEXTCLOUD_BASE_URL=${NEXTCLOUD_BASE_URL:-}
|
||
- NEXTCLOUD_WEBHOOK_SECRET=${NEXTCLOUD_WEBHOOK_SECRET:-}
|
||
- SECRET_KEY=${SECRET_KEY:-mulita-dev-secret-change-me}
|
||
- LOG_LEVEL=${LOG_LEVEL:-INFO}
|
||
- TZ=${TZ:-UTC}
|
||
# NullPool — see app/database.py for rationale.
|
||
- MULITA_CELERY_WORKER=1
|
||
depends_on:
|
||
redis:
|
||
condition: service_started
|
||
backend:
|
||
condition: service_started
|
||
db:
|
||
condition: service_healthy
|
||
healthcheck:
|
||
test: ["CMD-SHELL", "celery -A app.tasks.celery inspect ping -d light@$$HOSTNAME 2>/dev/null | grep -q OK"]
|
||
interval: 30s
|
||
timeout: 10s
|
||
retries: 3
|
||
start_period: 120s
|
||
networks:
|
||
- mulita-network
|
||
# Pin cloud.hubris.network to the LAN caddy IP. Without this, the
|
||
# docker DNS forwards the lookup to the host's resolver, which
|
||
# returns the public IONOS VPS IP — but cloud isn't in the VPS
|
||
# traefik exposure list, so TLS handshakes against it die with
|
||
# "unexpected eof while reading". Caddy on 192.168.8.175 holds the
|
||
# cloud.hubris.network cert and proxies to the Nextcloud LXC.
|
||
extra_hosts:
|
||
- "cloud.hubris.network:192.168.8.175"
|
||
restart: unless-stopped
|
||
|
||
# worker-watcher used to live here — it ran the long-lived
|
||
# watchfiles-based `watch_folders` task plus celery `--beat`. Both
|
||
# responsibilities moved on Phase 2:
|
||
# * file events now come from NC's webhook_listeners → POST
|
||
# /api/v1/internal/nc-webhook (see backend/app/routers/nc_webhook.py)
|
||
# * `--beat` was folded into worker-light's command so the
|
||
# periodic discard_missing_photos_beat job still fires.
|
||
|
||
worker-vision:
|
||
build:
|
||
context: ./backend
|
||
dockerfile: Dockerfile
|
||
image: mule-image-worker
|
||
container_name: mulita-worker-vision
|
||
command: sh -c "python -m app.services.vision.bootstrap_models && celery -A app.tasks.celery worker --loglevel=${LOG_LEVEL:-info} --concurrency=${CELERY_VISION_CONCURRENCY:-5} -Q vision -n vision@%h"
|
||
volumes:
|
||
- ./mulita.yml:/app/config/mulita.yml:ro
|
||
- ${PHOTO_DIRS:-./photos}:/photos:rw
|
||
- ${NEXTCLOUD_USERS_HOST_PATH:-./photos}:/nextcloud-users:rw
|
||
- thumbs_data:/data/thumbs
|
||
- proxies_data:/data/proxies
|
||
- db_data:/data/db
|
||
- models_data:/data/models
|
||
environment:
|
||
- DATABASE_URL=postgresql+asyncpg://mulita:mulita@db:5432/mulita
|
||
- REDIS_URL=redis://redis:6379
|
||
- CELERY_BROKER_URL=redis://redis:6379
|
||
- CELERY_RESULT_BACKEND=redis://redis:6379
|
||
- PHOTO_DIRS=/photos
|
||
- NEXTCLOUD_USERS_ROOT=${NEXTCLOUD_USERS_ROOT:-/nextcloud-users}
|
||
- NEXTCLOUD_BASE_URL=${NEXTCLOUD_BASE_URL:-}
|
||
- NEXTCLOUD_WEBHOOK_SECRET=${NEXTCLOUD_WEBHOOK_SECRET:-}
|
||
- SECRET_KEY=${SECRET_KEY:-mulita-dev-secret-change-me}
|
||
- LOG_LEVEL=${LOG_LEVEL:-INFO}
|
||
- TZ=${TZ:-UTC}
|
||
- MULITA_CELERY_WORKER=1
|
||
# ONNX Runtime execution providers. Set to "auto" to auto-detect
|
||
# GPU (CUDA > ROCm > OpenVINO > CPU), or explicitly:
|
||
# "CUDAExecutionProvider,CPUExecutionProvider"
|
||
# "ROCMExecutionProvider,CPUExecutionProvider"
|
||
# Default: CPU only. To enable GPU, also uncomment the deploy
|
||
# section below and install nvidia-container-toolkit on the host.
|
||
- VISION_EXECUTION_PROVIDERS=${VISION_EXECUTION_PROVIDERS:-CPUExecutionProvider}
|
||
# Pin each ONNX session to one intra-op thread so N prefork children
|
||
# × default-all-cores doesn't oversubscribe the box. With
|
||
# concurrency=5 and OMP=1, vision peaks at 5 busy cores, leaving
|
||
# one for worker-light + system. These env vars cover the three
|
||
# threading runtimes ONNX Runtime might pick up on first use.
|
||
- OMP_NUM_THREADS=1
|
||
- OPENBLAS_NUM_THREADS=1
|
||
- MKL_NUM_THREADS=1
|
||
# Uncomment for NVIDIA GPU passthrough:
|
||
# deploy:
|
||
# resources:
|
||
# reservations:
|
||
# devices:
|
||
# - driver: nvidia
|
||
# count: all
|
||
# capabilities: [gpu]
|
||
healthcheck:
|
||
test: ["CMD-SHELL", "celery -A app.tasks.celery inspect ping -d vision@$$HOSTNAME 2>/dev/null | grep -q OK"]
|
||
interval: 30s
|
||
timeout: 10s
|
||
retries: 3
|
||
start_period: 300s
|
||
depends_on:
|
||
redis:
|
||
condition: service_started
|
||
backend:
|
||
condition: service_started
|
||
db:
|
||
condition: service_healthy
|
||
networks:
|
||
- mulita-network
|
||
# Pin cloud.hubris.network to the LAN caddy IP. Without this, the
|
||
# docker DNS forwards the lookup to the host's resolver, which
|
||
# returns the public IONOS VPS IP — but cloud isn't in the VPS
|
||
# traefik exposure list, so TLS handshakes against it die with
|
||
# "unexpected eof while reading". Caddy on 192.168.8.175 holds the
|
||
# cloud.hubris.network cert and proxies to the Nextcloud LXC.
|
||
extra_hosts:
|
||
- "cloud.hubris.network:192.168.8.175"
|
||
restart: unless-stopped
|
||
|
||
db:
|
||
image: pgvector/pgvector:pg16
|
||
container_name: mulita-db
|
||
environment:
|
||
POSTGRES_USER: mulita
|
||
POSTGRES_PASSWORD: mulita
|
||
POSTGRES_DB: mulita
|
||
volumes:
|
||
- pg_data:/var/lib/postgresql/data
|
||
networks:
|
||
- mulita-network
|
||
# Pin cloud.hubris.network to the LAN caddy IP. Without this, the
|
||
# docker DNS forwards the lookup to the host's resolver, which
|
||
# returns the public IONOS VPS IP — but cloud isn't in the VPS
|
||
# traefik exposure list, so TLS handshakes against it die with
|
||
# "unexpected eof while reading". Caddy on 192.168.8.175 holds the
|
||
# cloud.hubris.network cert and proxies to the Nextcloud LXC.
|
||
extra_hosts:
|
||
- "cloud.hubris.network:192.168.8.175"
|
||
restart: unless-stopped
|
||
healthcheck:
|
||
test: ["CMD-SHELL", "pg_isready -U mulita -d mulita"]
|
||
interval: 5s
|
||
timeout: 5s
|
||
retries: 10
|
||
|
||
redis:
|
||
image: redis:7-alpine
|
||
container_name: mulita-redis
|
||
# Host port exposed only for local debugging; the backend / worker
|
||
# reach Redis via the internal mulita-network on its container name.
|
||
ports:
|
||
- "${REDIS_PORT:-6379}:6379"
|
||
volumes:
|
||
- redis_data:/data
|
||
networks:
|
||
- mulita-network
|
||
# Pin cloud.hubris.network to the LAN caddy IP. Without this, the
|
||
# docker DNS forwards the lookup to the host's resolver, which
|
||
# returns the public IONOS VPS IP — but cloud isn't in the VPS
|
||
# traefik exposure list, so TLS handshakes against it die with
|
||
# "unexpected eof while reading". Caddy on 192.168.8.175 holds the
|
||
# cloud.hubris.network cert and proxies to the Nextcloud LXC.
|
||
extra_hosts:
|
||
- "cloud.hubris.network:192.168.8.175"
|
||
restart: unless-stopped
|
||
command: redis-server --appendonly yes
|
||
healthcheck:
|
||
test: ["CMD", "redis-cli", "ping"]
|
||
interval: 10s
|
||
timeout: 5s
|
||
retries: 5
|
||
|
||
networks:
|
||
mulita-network:
|
||
driver: bridge
|
||
|
||
volumes:
|
||
thumbs_data:
|
||
proxies_data:
|
||
db_data:
|
||
redis_data:
|
||
pg_data:
|
||
models_data: |