Removes the OpenCLIP-on-ONNX classifier and everything that fed or
consumed it:
- backend: app/services/vision/, app/tasks/vision.py,
app/services/feature_flags.py, app/routers/features.py — all
deleted; admin AI/feature-flag endpoints and the worker-vision
bootstrap call gone. Photo.needs_review and its index dropped.
- frontend: AI Settings tab, useFeaturesQuery hook, FeatureFlag
types, "Needs Review" sidebar entry + filter, needs_review filter
URL param all gone.
- infra: worker-vision compose service + models_data volume deleted;
worker-light command no longer runs bootstrap_models; the db
image switches from pgvector/pgvector:pg16 to postgres:16; backend
Dockerfile drops the dedicated torch RUN layer; requirements.txt
drops torch/torchvision/open-clip-torch/onnxruntime.
Alembic 0019_drop_ai_remnants:
- drops photos.needs_review + ix_photos_needs_review
- DROP EXTENSION IF EXISTS vector (must run before the image swap;
the new postgres:16 doesn't ship pgvector)
New scripts/full_refresh.py: one-shot DB ↔ filesystem reconciliation.
Runs cleanup_data_integrity, scans every active SourceRoot inline
(no celery dependency so the worker can be stopped), hard-prunes
photo + folder rows for files that are gone, removes orphan
/data/thumbs/{user}/{photo}/ directories. New helper
prune_orphan_thumbnails in cleanup.py.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
268 lines
12 KiB
YAML
268 lines
12 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
|
||
- 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 "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
|
||
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.
|
||
|
||
db:
|
||
image: postgres:16
|
||
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: |