Without this, the docker default resolver forwards the lookup to the host gateway, which returns the public IONOS VPS IP. cloud.hubris is not in the VPS traefik exposure list, so TLS handshakes during WebDAV calls die with httpx.ConnectError: SSL UNEXPECTED_EOF. extra_hosts pins it to caddy on 192.168.8.175, which holds the cloud.hubris.network cert and proxies to the Nextcloud LXC. Applied to every service for symmetry; only backend currently makes the WebDAV calls. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Mulita - Self-Hosted Photo Management Application
A self-hosted, Docker-deployed photo management application inspired by Lightroom's workflow. Mulita provides a fast, keyboard-driven interface to browse, organize, tag, and manage your photo library.
Features
- Photo Organization: Browse photos in a timeline view with virtual scrolling for performance
- Thumbnail Generation: Automatic thumbnail generation for all photo formats including RAW
- Metadata Extraction: Full EXIF/XMP metadata extraction and GPS mapping
- Keyboard Shortcuts: Lightroom-style keyboard navigation and actions
- File Support: JPEG, PNG, RAW formats (CR2, CR3, NEF, ARW, etc.), HEIC/HEIF, and videos
- Heaps: Temporary collections for organizing photos
- Tags & Ratings: Organize with tags, star ratings, and color labels — each with a card-grid browse view that drills into a full Timeline detail
- Dark Mode: Photography-optimized dark interface
- Vision Pipeline: YOLO object detection, OCR text extraction, CLIP embeddings for semantic search, InsightFace face detection and clustering
- People View: Browse identified people as cards, click to see all photos of a person
- Map View: Browse GPS-tagged photos on an interactive Leaflet map
- Duplicate Detection: Perceptual hash-based duplicate grouping with best-pick UI
- Semantic Search: Natural-language photo search powered by CLIP embeddings
Tech Stack
Backend
- Python 3.12 with FastAPI
- PostgreSQL + pgvector with SQLAlchemy (async) and Alembic migrations
- Celery + Redis for background tasks
- pyvips for fast thumbnail generation
- ExifTool for metadata extraction
- ONNX Runtime for vision models (YOLO, CLIP, InsightFace)
Frontend
- React 18 with TypeScript
- Vite for fast development
- TanStack Query for data fetching
- TanStack Virtual for virtualized scrolling
- Tailwind CSS for styling
- Zustand for state management
Quick Start
Prerequisites
- Docker and Docker Compose
Setup (one variable)
- Clone the repo:
git clone <repository-url>
cd muleimage
-
Copy the example env file and set one variable — the host directory that contains your photo library. Whatever you point at will become your library inside Mulita.
cp .env.example .env # then edit .env and set PHOTO_DIRS: # macOS / Linux: PHOTO_DIRS=/Users/you/Pictures # Network share: PHOTO_DIRS=/mnt/nas/photos # Windows (WSL): PHOTO_DIRS=/mnt/c/Users/you/Pictures -
Start the stack:
docker compose up -d
- Open
http://localhost:3000. On first boot Mulita will:- Mount your
PHOTO_DIRSat/photosinside the container - Auto-create a source root called Library pointing at
/photos - Queue an initial scan, generate thumbnails, and start serving them
- Mount your
You don't need to touch mulita.yml or the API to get started.
Configuration knobs
Everything is environment-driven. PHOTO_DIRS is the only required
value; the rest have sensible defaults documented in .env.example:
| Variable | Default | Notes |
|---|---|---|
PHOTO_DIRS |
— | Required. Host path mounted at /photos. |
FRONTEND_PORT |
3000 |
SPA host port. Bump if 3000 is taken. |
BACKEND_PORT |
8001 |
Direct backend port (debug only — frontend uses internal nginx proxy). |
REDIS_PORT |
6379 |
Redis host port (internal services don't need it). |
ALLOWED_ORIGINS |
* |
Comma-separated CORS origins for direct backend access. Lock down for prod, e.g. https://photos.example.com. |
LOG_LEVEL |
INFO |
Backend + worker log level. DEBUG for chasing scan issues. |
TZ |
UTC |
Container timezone. Affects log timestamps and "added at". |
CELERYD_CONCURRENCY |
4 |
Parallel worker processes (scans, thumbs, metadata). Lower on a Pi, higher on a beefy host. |
Accessing from another machine
The frontend talks to the backend through its bundled nginx, which
proxies /api/ to the backend on the internal compose network. That
means requests are always same-origin as the page, so accessing
Mulita from another host works without any CORS dance:
http://<your-server-ip>:3000
If you want to put it behind a reverse proxy at e.g.
https://photos.your.tld, set ALLOWED_ORIGINS to that host so the
backend's direct port (BACKEND_PORT) also accepts cross-origin
requests if anything bypasses the proxy.
How libraries are managed
Mulita is config-driven: the host directory you mount via
PHOTO_DIRS becomes your library, and the backend automatically
registers it as a source root on startup. There is no UI for adding
or removing source roots — to change what Mulita scans, edit .env
(or docker-compose.yml for multi-mount setups) and restart the
stack.
This keeps the model simple: the docker mount IS the library. No two layers, no confusion about which view to use.
Changing or adding libraries
To point at a different library:
- Edit
PHOTO_DIRSin.env docker compose down- (Optional, for a clean slate)
docker volume rm muleimage_db_data muleimage_thumbs_data muleimage_proxies_data docker compose up -d
The new library shows up automatically. Without step 3 the old library's metadata stays in the DB and you'll see a warning at startup that the old source root's path is missing on disk — that's a hint to clean up.
For multiple libraries, edit docker-compose.yml and add additional
mount lines:
volumes:
- ${PHOTO_DIRS}:/photos:rw
- /Volumes/Archive:/archive:rw # additional library
Each mounted directory will need a corresponding source root row in
the DB; today that means POST /api/v1/folders via curl, or wait
for the multi-mount auto-registration that's on the roadmap.
Read-only libraries
The default mount is :rw because file operations (rename, move,
empty discard pile) need to mutate the filesystem. If you want a
strict read-only library — pointing at a network share, an
authoritative archive, etc. — flip :rw to :ro in
docker-compose.yml. Mulita will keep working for browsing, rating,
color labels, picks, heaps, and the (soft) discard flag, but the
following will return an OS error:
PATCH /photos/{id}with a newfilename(rename)POST /photos/move(bulk move)DELETE /discard/empty(file unlinks)
Heads up: with :rw, Mulita has full write access to whatever
host directory you mount. Treat the same way you would Lightroom's
catalog folder.
Architecture
The application consists of 5 Docker services:
- frontend: React SPA served by Nginx
- backend: FastAPI REST API
- worker: Celery workers for background tasks (thumbnails, metadata, vision pipeline)
- redis: Message broker for Celery
- db: PostgreSQL with pgvector extension (for CLIP/face embeddings)
Keyboard Shortcuts
| Key | Action |
|---|---|
← → ↑ ↓ |
Navigate photos |
Space |
Quick preview |
Enter |
Open loupe view |
T |
Add to active heap |
1-5 |
Set star rating |
Tab |
Toggle left sidebar |
I |
Toggle metadata panel |
G |
Grid view |
E |
Loupe view |
Delete |
Move to trash |
Development
Backend Development
cd backend
pip install -r requirements.txt
uvicorn app.main:app --reload
Frontend Development
cd frontend
npm install
npm run dev
Configuration
Source roots are managed by the UI / API (the database owns them). Edit
mulita.yml to configure operational settings only:
- Thumbnail sizes, quality, and format
- Scanner behaviour (watch, batch size, initial scan)
- Performance tuning (concurrency, cache TTLs, DB pool)
Performance
- Handles 100,000+ photos efficiently
- Virtual scrolling for smooth timeline navigation
- Thumbnail generation at 10+ photos/second
- PostgreSQL full-text search with tsvector indexing
- pgvector for fast nearest-neighbor embedding search
Future Features
- Smart albums (auto-populated by saved filters)
- Export presets
- Multi-user support
License
MIT