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: