# 120 — `mule-images` Hosts `mule-image` — the photos app at `photos.hubris.network`. PhotoPrism + Go sidecar + SvelteKit, replacing the legacy FastAPI/Celery stack as of 2026-05-22 (see Changelog). Auto-deploys from `dtoro/mule-image` on `git push origin main`. ## At a glance - **Hostname:** `mule-images` - **IP:** `192.168.8.136` - **Privilege:** privileged - **Resources:** 6 cores / 12 GiB RAM / 60 GiB rootfs / 2 GiB swap - **Mounts:** `/mnt/library` ↔ `/mnt/library`; `/dev/dri/{card0,renderD128}` passed through for VA-API video accel on the AMD Phoenix1 iGPU. - **Public hostname:** [`photos.hubris.network`](../infrastructure/dns.md) → [caddy](121-caddy.md) → path-routed to PhotoPrism `:2342` / sidecar `:8000` / nginx-static `:3000`. ## Stack (`/opt/mule-image`) `/opt/mule-image` IS the working tree of `dtoro/mule-image`. Compose stack: base `docker-compose.yml` + LAN-port-rebind `docker-compose.override.yml` (untracked) + VA-API `docker-compose.gpu.yml`. The SvelteKit frontend is built on the host and served as a static bundle by nginx — no `vite dev` in prod. | Service / process | Port (LAN) | Notes | | ------------------------ | --------------------- | ----- | | pp-mariadb | (internal 3306) | MariaDB 11; holds PhotoPrism's `photoprism` DB + sidecar's `mule_sidecar.*` | | pp-app | `0.0.0.0:2342` | PhotoPrism `:latest`; `PHOTOPRISM_FFMPEG_ENCODER=h264_vaapi` | | pp-sidecar | `0.0.0.0:8000` | Go service (file rename / folder ops / heap convert / dup scan / per-photo marks); also reconciles `USER_BASEPATHS` into PhotoPrism's `auth_users` every 60 s | | nginx (host process) | `0.0.0.0:3000` | Serves the SvelteKit static bundle from `web/build/` | **Per-user scoping** is driven by `USER_BASEPATHS=admin:admin/files, muli:muli/files` in `.env`. Sidecar applies it to PhotoPrism's `auth_users` table on boot + every 60s, `mkdir -p`s each target so PhotoPrism's ACL filter has somewhere to point. `.env` is **untracked** — `git reset --hard` won't touch it, but `git clean -fdx` would. Holds PP_*, MariaDB passwords, SIDECAR_DB_PASSWORD, OIDC_* (existing `mule-image` Authentik app, redirect URI now `/api/v1/oidc/redirect`), `USER_BASEPATHS`, and `PP_FFMPEG_ENCODER=vaapi`. `docker-compose.override.yml` is also untracked — it `!override`s the upstream `127.0.0.1:port` mappings to `0.0.0.0:port` so cross-host Caddy on [LXC 121](121-caddy.md) can reach pp-app + sidecar. ## Library access PhotoPrism reads the library **directly off the bind-mounted filesystem** — no Nextcloud webhook integration in the new stack. The base path is `/mnt/library/homecloud` and per-user scoping comes from `USER_BASEPATHS` (see Stack section above). - `dtoro` is mapped to NC user `admin` → `/mnt/library/homecloud/admin/files/` - `muli` is mapped to NC user `muli` → `/mnt/library/homecloud/muli/files/` PhotoPrism's container user is `33:10000` (`www-data:media`), matching the host ownership of the NC tree. The legacy `oc_webhook_listeners` rows + `NEXTCLOUD_WEBHOOK_SECRET` are gone — when NC writes via WebDAV (still its own primary surface), PhotoPrism picks up the new file on its next index pass. ## Authentication OIDC via Authentik. App slug `mule-image`, redirect URI `https://photos.hubris.network/api/v1/oidc/redirect` (PhotoPrism's auto-derived path; PhotoPrism builds it from `PHOTOPRISM_SITE_URL`). `OIDC_REGISTER=true` auto-creates a PhotoPrism user at role `user` on first SSO login. The sidecar's basepath reconciler then assigns their scoped folder. The `mule-image` Authentik app's redirect URI was migrated from the legacy FastAPI `/api/v1/auth/oidc/callback` path on 2026-05-22 — same client ID/secret were reused. The separate `mule-photos-new` Authentik app was deleted in the same operation. ## Auto-deploy Push to `dtoro/mule-image` `main` → gitea webhook → `http://192.168.8.136:9797/deploy` → `mule-deploy-webhook.service`: - Validates HMAC against `/etc/mule-deploy/secret` - Filters to `refs/heads/main` - Runs `/opt/mule-deploy/deploy.sh` in a daemon thread (returns 202 immediately — docker builds exceed gitea's request timeout) - `git fetch && git reset --hard origin/main`, force-recreates **only the sidecar** image (so PhotoPrism's `Session` HMAC key in `pp/storage/config/hub.yml` doesn't rotate and invalidate every in-flight OIDC state cookie), reconciles `pp-app` + `mariadb` in place, `cd web && npm ci && npm run build`, `systemctl reload nginx`. - Conditionally layers `docker-compose.gpu.yml` when `/dev/dri/renderD128` exists, and always layers `docker-compose.override.yml` (the LAN-port rebind) when present. Deploy tooling is **outside** the app repo: `/opt/mule-deploy/{deploy.sh,webhook.py}`, secret at `/etc/mule-deploy/secret`. Gitea webhook id 6. `app.ini` `ALLOWED_HOST_LIST` on [gitea](104-gitea.md) includes `192.168.8.136`. Logs: `pct exec 120 -- journalctl -u mule-deploy-webhook -f`. Manual deploy: `pct exec 120 -- /opt/mule-deploy/deploy.sh`. For pushes from inside the LXC, gitea creds at `/etc/mule-deploy/git-credentials` (mode 600) — same token as `/etc/caddy-deploy/git-credentials` on [caddy](121-caddy.md). ## Related - [Nextcloud (114)](114-nextcloud.md) — source of truth for photo libraries - [Authentik (124)](124-authentik.md) - [Caddy (121)](121-caddy.md) - [DNS](../infrastructure/dns.md) - [Auto-deploy](../infrastructure/auto-deploy.md) - [Gitea (104)](104-gitea.md) ## Changelog ### 2026-05-22 — Cutover to PhotoPrism stack (Mulimage 2.0) The `new` branch of `dtoro/mule-image` was merged into `main` as commit `70dc1b6`. The merge replaces the legacy FastAPI + PostgreSQL + Celery + React stack with PhotoPrism + Go sidecar + MariaDB + SvelteKit, lifted in from the M0 evaluation on (now-destroyed) [LXC 127](#). Cutover on 120 (this session): - Bumped to 6 cores / 2 GB swap. `/dev/dri/{card0,renderD128}` already passed through; added an opt-in `docker-compose.gpu.yml` overlay that the deploy script layers in when the device is present. PhotoPrism now uses `h264_vaapi` instead of `libx264`. - Installed host nginx + a small `photos.conf` vhost serving the SvelteKit static bundle from `/opt/mule-image/web/build/` on `:3000` — no Vite dev server in prod. SvelteKit's `adapter-static` produces a real SPA bundle. - Replaced `/opt/mule-deploy/deploy.sh` with the 127-style multi-compose deploy (force-recreates only sidecar to preserve PhotoPrism's Session HMAC key; reconciles pp-app + mariadb in place; rebuilds web/ and reloads nginx). - `/opt/mule-image/.env` was rewritten to the PhotoPrism shape. Reused passwords from `/root/mule-photos-new-secrets.txt`. `USER_BASEPATHS= admin:admin/files, muli:muli/files` exposes both libraries. - `docker-compose.override.yml` (untracked) `!override`s pp-app + sidecar ports to `0.0.0.0` so cross-LXC caddy on [121](121-caddy.md) can reach them. - Authentik: `mule-image` app's redirect URI updated to `/api/v1/oidc/redirect`; the separate `mule-photos-new` app deleted. - Caddyfile (`dtoro/caddy-conf`): `photos.hubris.network` switched from plain passthrough to path-matchers (PhotoPrism API + sidecar API + nginx static catch-all), and `photos-new.hubris.network` was removed entirely. - Cleanups: 4 Nextcloud `oc_webhook_listeners` rows for the legacy `/api/v1/internal/nc-webhook` endpoint deleted; gitea webhook id 9 (the `refs/heads/new` deploy hook for 127) deleted; `192.168.8.181` removed from gitea's `ALLOWED_HOST_LIST`; `photos-new.hubris.network` removed from dnsmasq. - LXC 127 destroyed via `pct destroy 127` after the new stack passed curl verification end-to-end (PP `/api/v1/status` returns `{"status":"operational"}` through caddy; sidecar `/api/sidecar/healthz` returns `{"ok":true,...}`). - Rollback safety net: legacy `mulita` postgres dump at `/root/backups/mulita-legacy-final-20260521-212036.sql.gz` on hubris (10 MB, 16,155 photo rows). The `mule-image_mule_db_data` docker volume on 120 retains the on-disk postgres data for at least 24 h before housekeeping removes it. ### 2026-05-14 — Drop AI/vision pipeline, plain Postgres, DB↔FS refresh **AI removal (commits `a27267f` + `423a73a`).** The OpenCLIP-on-ONNX binary classifier (photography vs other) and all its scaffolding gone: - Backend: `app/services/vision/`, `app/tasks/vision.py`, `app/services/feature_flags.py`, `app/routers/features.py` deleted. Admin `/admin/feature-flags`, `/admin/ai/{backfill,rescan}` endpoints removed. `Photo.needs_review` column + `ix_photos_needs_review` index dropped (alembic `0019_drop_ai_remnants`). - Frontend: AI Settings tab, `useFeaturesQuery`, "Needs Review" sidebar entry + filter, `needs_review` URL param plumbing, FeatureFlag types all gone. - Infra: `worker-vision` compose service + `models_data` volume deleted. `worker-light` no longer runs `python -m app.services.vision.bootstrap_models` before celery. Backend Dockerfile drops the dedicated torch RUN layer. `requirements.txt` drops `torch`, `torchvision`, `open-clip-torch`, `onnxruntime`. **Postgres image swap.** `pgvector/pgvector:pg16` → `postgres:16`. The 0019 migration drops the (now-unused) `vector` extension first; the SQL was pre-applied via `psql` against the still-pgvector container and `alembic_version` stamped to `0019` so the new backend's `bootstrap.py` upgrade-head was a no-op. After the swap surfaced a collation-version mismatch (Bookworm glibc 2.36 → Trixie glibc 2.41), the fix was `REINDEX DATABASE mulita` + `ALTER DATABASE … REFRESH COLLATION VERSION` on `mulita`, `postgres`, `template1`. The data volume was preserved across the image change. **One-shot DB ↔ filesystem refresh.** New script `backend/scripts/full_refresh.py` (run as `docker exec -w /app mulita-backend python -m scripts.full_refresh [--dry-run]`). Phases: `cleanup_data_integrity` → inline scan of every active SourceRoot → `prune_missing_photos(dry_run=False)` → new `prune_orphan_thumbnails` helper that removes `/data/thumbs/{user}/{photo}/` dirs for photo_ids that left the DB. First run: 0 missing photos, 1 stale folder row, **7982 orphan thumbnail dirs** reaped. **Manual cleanup post-deploy.** `docker compose up -d --build --force-recreate` doesn't reap services removed from the YAML, so `mulita-worker-vision` had to be `docker rm -f`'d by hand. The `mule-image_models_data` volume was likewise removed manually. ### 2026-05-11 — Stop duplicating Nextcloud's indexing (Phase 1 + 2) Two big architectural shifts in one session, both aimed at killing work mule was doing that NC already does for the same source files. **Phase 1 — thumbnail proxy (commits `576b0c2`, `28738ac`).** Photos table gains `nextcloud_fileid` (migration `0018`). `GET /api/v1/photos/{id}/thumb/{size}` proxies NC's `/index.php/core/preview` keyed by that fileid, authenticated with the owner's encrypted app password (small=240, medium=640, large=1280). Worker now only writes the `medium` size to `/data/thumbs` (the vision worker still loads it from disk); `small` + `large` no longer touch disk. Disk fallback stays wired for legacy rows and the rare 404 from NC (iPhone JPEGs mis-extensioned as `.DNG` — verified). Existing 17,591 photos backfilled via `backend/scripts/backfill_nextcloud_fileid.py`. Tested with real CR2, real DNG, fake DNG, JPEG, HEIC — all green. **Range support for `/original` (commit `18dce33`).** Pre-existing bug surfaced by Phase 1 testing: `