Files
mule-image/docker-compose.yml
Claudio 68bbe6f024 fix(compose): mount video_cache_data volume on backend + workers
The /playback transcode cache lives at /data/video-cache. That
directory was created in container-local storage (the mkdir at
services/video.py import time), not on a shared volume — so the
worker pretranscode populated its own ephemeral copy while the
backend served from a different empty one, and the cache evaporated
on every container restart.

Add a video_cache_data named volume mounted on backend, worker-light,
and worker-vision so the pretranscoded files actually reach the
serving path and survive deploys.
2026-05-12 00:09:25 +02:00

347 lines
15 KiB
YAML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
- video_cache_data:/data/video-cache
- 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
- video_cache_data:/data/video-cache
- 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
- video_cache_data:/data/video-cache
- 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:
video_cache_data:
db_data:
redis_data:
pg_data:
models_data: