Watch
1
Fork
You've already forked imora
0
High performance self-hosted photo and video management solution. https://imora.vexcited.com
  • Rust 69.1%
  • TypeScript 21.6%
  • Lua 3.9%
  • JavaScript 3.7%
  • Shell 1.4%
  • Other 0.2%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
2026-10-05 19:21:29 +02:00
.cargo refactor: split the server into a cargo workspace of crates 2026-09-30 01:19:15 +02:00
.forgejo/workflows ci: shorten the release workflow with existing actions 2026-10-05 19:21:29 +02:00
crates refactor: identify imora in /server/version instead of its own route 2026-10-05 17:07:23 +02:00
deploy ci: deploy stable releases through the container's update command 2026-10-05 17:25:21 +02:00
docker fix: allow the email logo and drop the immich hosts from links and the csp 2026-10-05 16:20:42 +02:00
docs refactor: identify imora in /server/version instead of its own route 2026-10-05 17:07:23 +02:00
proxmox feat: rename the protocol to imora and convert immich installs at startup 2026-10-05 15:50:48 +02:00
web fix: keep inputs mounted while typing in the admin settings 2026-10-05 16:56:26 +02:00
.env.example ci: deploy releases through the production webhook 2026-10-05 17:39:26 +02:00
.gitignore ci: deploy releases through the production webhook 2026-10-05 17:39:26 +02:00
Cargo.lock chore: remove the dev and bench tooling 2026-10-05 13:16:59 +02:00
Cargo.toml chore: remove the dev and bench tooling 2026-10-05 13:16:59 +02:00
LICENSE chore: add the agpl license 2026-10-02 01:36:16 +02:00
README.md ci: deploy releases through the production webhook 2026-10-05 17:39:26 +02:00
rustfmt.toml chore: set up cargo workspace and repository tooling 2026-09-26 02:10:16 +02:00

Imora

A clean room reimplementation of the Immich and Noodle Gallery server in Rust, with a new web app. Imora speaks the same HTTP API and runs on an existing Immich database, so it can take over from a running instance.

Install

Imora is two programs: imora, the server with the web app built in, and imora-ml, the machine learning service. Both run on Linux, amd64 or arm64. They need PostgreSQL with pgvector or VectorChord, Redis or Valkey, and for the server libvips, ffmpeg and ExifTool 13.59. On an empty database Imora creates the schema itself.

Docker

docker/compose.yml has the shape of Immich's compose file, with Imora's images:

mkdir imora && cd imora
curl -fsSLO https://code.vexcited.com/imora/imora/raw/branch/main/docker/compose.yml
curl -fsSL -o .env https://code.vexcited.com/imora/imora/raw/branch/main/docker/example.env
# set UPLOAD_LOCATION, DB_DATA_LOCATION and DB_PASSWORD in .env
docker compose up -d

The images are code.vexcited.com/imora/imora and code.vexcited.com/imora/imora-ml, tagged with the release version and latest. On amd64 the machine learning image also comes as -cuda and -openvino, docker/hwaccel.ml.yml and docker/hwaccel.transcoding.yml pass the devices through.

Proxmox VE

Run on the Proxmox host, it creates a Debian 13 container with everything in it:

bash -c "$(curl -fsSL https://code.vexcited.com/imora/imora/raw/branch/main/proxmox/ct/imora.sh)"

Inside the container, update installs the latest release. proxmox/install/imora-install.sh also installs Imora on any Debian 13 machine.

Binaries

Every release carries imora-<version>-linux-<amd64|arm64>.tar.gz with both binaries. The systemd units are the ones proxmox/install/imora-install.sh writes.

Imora works on the database and the files Noodle Gallery 5.7.0 leaves, and never migrates the schema. Once it runs, going back is a matter of starting Gallery again on the same database and files.

  • Noodle Gallery 5.7.0 switches directly.
  • Immich up to 3.2 first starts Noodle Gallery 5.7.0 once, which migrates the database to its schema, then switches. Immich 3.3 and later add migrations Gallery 5.7.0 does not know, it cannot take them over yet.

Make a database dump before anything, Imora has no backup feature:

docker exec -t immich_postgres pg_dumpall --clean --if-exists --username=postgres | gzip > dump.sql.gz

On its first start Imora renames what Immich named after itself: the database function immich_uuid_v7 becomes imora_uuid_v7, with the triggers that call it, the .immich markers of the storage folders become .imora, and the queues move from immich_bull to imora_bull in redis. Gallery and Immich do not start on that database afterwards without renaming them back, and their mobile apps do not connect to Imora.

Environment variables

Immich's and Gallery's IMMICH_* variables are named IMORA_* in Imora, which warns about any IMMICH_* one left at startup. DB_*, REDIS_* and MACHINE_LEARNING_* keep their names. The machine learning service listens on IMORA_ML_HOST and IMORA_ML_PORT where Immich's read IMMICH_HOST and IMMICH_PORT.

sed -i 's/^IMMICH_/IMORA_/' .env

Docker

  1. From Immich up to 3.2: replace the server image with ghcr.io/open-noodle/gallery-server:v5.7.0, run docker compose up -d, wait for Immich Server is listening in docker compose logs immich-server, then docker compose down.
  2. In docker-compose.yml, replace the server image with code.vexcited.com/imora/imora:<version> and the machine learning one with code.vexcited.com/imora/imora-ml:<version>, adding -cuda or -openvino when the old one had it. Keep the volumes, the database and redis services as they are: the media stays mounted at /data, where the database's paths point. Rename the immich-machine-learning service to imora-machine-learning, the url Imora's settings default to, or set that url in the admin settings.
  3. Rename the variables in .env as above. In the machine learning service, IMMICH_PORT becomes IMORA_ML_PORT.
  4. docker compose up -d.

Installs from before Immich 1.137 mount the media at /usr/src/app/upload. Keep that mount, or mount it at /data: Imora rewrites the stored paths on its first start, like Immich does when its media folder moves.

Proxmox and bare metal

For the community-scripts Immich LXC, a Gallery install, or any install with systemd units:

  1. Stop and disable the old services, systemctl disable --now immich-web immich-ml.
  2. Run proxmox/install/imora-install.sh with IMORA_EXISTING=1, it installs Imora next to the old install and leaves the database and media alone.
  3. Write /opt/imora/.env from the old .env, renamed as above, with DB_* pointing at the existing database and IMORA_MEDIA_LOCATION at the existing media folder. Moving the media is fine: point IMORA_MEDIA_LOCATION at the new folder and Imora rewrites the stored paths on its first start.
  4. systemctl enable --now imora imora-ml.
  5. Once it works, remove the old install. Keep the database dump until you are sure.

Layout

path what
crates/ the server, a Cargo workspace of the crates below: Tokio, axum, and tokio-postgres on the Immich schema
web/ the web app: Solid 2, @solidjs/router, @solidjs/meta, UnoCSS, unplugin-icons, Vite, Bun
deploy/ the security headers, and a script putting a build of this checkout on production
docker/ Dockerfiles of the server and machine learning images, compose file
proxmox/ Proxmox VE container script and the Debian installer it runs
.forgejo/ release workflow

Crates

The server is split into crates so a change rebuilds only what depends on it, and the crates at the same level compile in parallel. The graph is wide and shallow: pure libraries at the bottom, one vocabulary crate, feature crates side by side on top of it, then the api and the binary.

crate what
imora-js javascript semantics gallery's output depends on: Number#toString, Number(), JSON.stringify, Date, lodash and luxon
imora-html-to-text html-to-text 9.0.5 with htmlparser2's tokenizer, the plain text of emails
imora-emails gallery's react email templates, filled like react renders them
imora-user-agent ua-parser-js 2.0.10 with javascript regex semantics, for session device names
imora-bullmq a bullmq 5.81.3 client on gallery's queues in redis
imora-media mime tables, libvips and sharp's pipeline, ffmpeg and ffprobe, exiftool, thumbhash
imora-ml gallery's machine learning service on onnx runtime, and the imora-ml binary that serves it
imora-core config, errors, database, request validation, auth and access, system config, jobs and the worker loop, the socket, AppState
imora-people people and faces, suggestions, face repair
imora-assets asset details and updates, files, metadata, edits, duplicates, stacks, downloads, hls
imora-sharing albums, activities, shared links, notifications
imora-sync the mobile apps' sync streams
imora-admin user administration, server statistics, storage migration, integrity checks
imora-upload uploads, with multer and busboy's multipart handling
imora-library timeline, search, map, memories
imora-jobs gallery's job handlers and what they hand over to
imora-api the http routes and the web app
imora the binary: the server, the machine learning service and the admin commands

Implemented so far

  • password login, logout, token validation, change password

  • OAuth / OpenID Connect: authorize with PKCE, callback, account link and unlink, mobile redirect, back-channel logout, RP initiated logout with id_token_hint

  • sessions (cookies, bearer, x-imora-* headers), child sessions, PIN code and locked sessions

  • API keys with permission checks

  • /server/{ping,version,config,features,about}, /users/me and /users/:id/profile-image, OAuth logins sync the storage quota and label claims on every login like Immich 3.3, and import the provider picture claim as a thumbnail in the configured format when the user has no profile picture

  • timeline: /timeline/{buckets,bucket,bucket-covers} with every filter of gallery's TimeBucketDto except shared spaces and ocr, which fail with a 500 instead of answering differently, and /assets/:id/thumbnail. The web app shows the whole library as a justified grid by month and day with a date scrubber on the right.

  • albums: every /albums route (list with filters, statistics, names, create, update, delete, map markers, adding and removing assets, sharing with roles, leaving), /activities comments and likes, and GET /users for sharing. Invites and new photos go through gallery's notification jobs (updates after gallery's five minute delay), which write the in app notification and send the email. The web app has an albums page, album pages with sharing, activity, cover and sort order, and adding photos from the timeline selection.

  • shared links: every /shared-links route (album and individual links, passwords with gallery's cookie, custom slugs, expiry, hidden metadata, adding and removing assets), and shared link credentials on the album, timeline and thumbnail routes. Links created from a shared space fail with a 500. /share/:key and /s/:slug carry gallery's open graph tags. The web app has a link dialog on albums and the photos selection, a shared links page and the public share page.

  • downloads: /download/info and /download/archive (stored zips streamed as files are read, form posts included) and /assets/:id/original. Albums, selections and shared links can be downloaded from the web app.

  • uploads: POST /assets (multer and busboy ported, so malformed forms, field order, latin1 file names and every error answer like gallery), the x-imora-checksum and constraint duplicate paths, quota, live photo links, shared link uploads, POST /assets/bulk-upload-check, GET /server/media-types and POST/DELETE /users/profile-image through the same libvips pipeline as gallery's. The web app uploads from the header button, drag and drop (folders too) or paste, hashes each file in a worker to skip duplicates, shows gallery's upload panel, uploads into albums from their page and lets shared link visitors upload when the link allows it. Profile pictures are set from account settings.

  • asset viewer: GET/PUT/PATCH /assets/:id, bulk PUT/PATCH/DELETE /assets, /assets/:id/ocr, /assets/:id/video/playback, /trash/restore/assets and /stacks/:id. The web app opens photos and videos from the photos, album, person and share pages at <page>/photos/:id with zoom, pinch, swipes, keyboard shortcuts, live photos, stacks, the ocr overlay and a details panel where owners edit the description and date. Owners can favorite, archive, trash, share and add to albums from the viewer, album members can set the cover or remove the photo.

  • people: every /people route (the identity based list, search, statistics, one person, their faces and face crops, create, update, hide, delete, merge, same person merges, separating a profile, representative faces, reassigning), /faces (list, draw, reassign, remove), face suggestions and GET /search/person, plus person filters on the timeline. Shared space profiles and merges that would combine another user's people fail with a 500. The web app has a people page (people and pets, search, hiding), person pages with their timeline, renaming, birthdays, portraits, merging, moving photos to someone else and reviewing suggestions, and a face editor in the viewer.

  • search: every /search route. Metadata, smart (clip) and random search, statistics, large assets, the smart search facets, explore, places, cities and the suggestion lists, all running gallery's flat search. Like the fork, the structured filter tree is validated and then refused. Smart search asks the machine learning server to encode the text, a request, not a job. Searches that would reach a shared space fail with a 500. The web app has a search palette in the header (/ or ⌘K) with smart, file name, description and text modes and live people, place, album and tag matches, a search page with removable chips and a filter panel, "show similar photos" in the viewer, and explore and places pages.

  • memories: every /memories route. The list with gallery's filters (date, saved, upcoming, trashed, type, paging, random order), statistics, one memory, create, update, delete and adding or removing photos. The list honours the admin's memory type switches and each user's, and leaves out archived, locked and hidden person photos. The nightly MemoryGenerate job is ported: on this day memories and every rule of the fork (recent trips, month and season recaps, favorites and person throwbacks, people together, places, video moments, themes found through clip search) with its overlap reservation, and birthday memories following Immich 3.3 (photos taken on the person's birthday over the years, shown from three days before), stored as the fork's birthday rule memories so its mobile app shows them. MemoryCleanup keeps the configured retention. The web app shows memories on explore, a memories page with its upcoming and favorites filters, and a memory viewer that plays each photo for five seconds, with favorite, remove, keyboard and swipe navigation and the memory's photos below.

  • recently added: the timeline ordered and grouped by date added, like gallery's recently added page, reached from explore.

  • accounts and administration: /users/me updates, /users/me/preferences, /assets/statistics, every /admin/users route (list, create, get, update, delete, restore, sessions, statistics, preferences) and /system-config (read, defaults, save). The config is validated against gallery's own admin schema, dumped from its compiled zod into crates/imora-core/src/system_config/gallery.json, and saved as the part that differs from the defaults like gallery does. A save tells gallery's nodes through the socket cluster, so they reload it at once. Creating a user with notify queues the welcome email. The erasing job of a forced delete is left out, deleted accounts are finished after the configured delay. The web app has a sidebar (photos, explore with people, places, memories and recently added, favorites, albums with the recent ones, archive, settings and, for admins, users and system settings) that follows the sidebarWeb preferences, user settings (account, password and PIN, devices, API keys, preferences) and admin settings for authentication, server, memories, trash and account deletion.

  • locked folder: /locked shows the locked timeline once the session is unlocked, otherwise /auth/pin-prompt asks for the PIN (or creates the first one) and comes back. Photos move in and out from selections and the viewer with gallery's confirmations, locked photos are deleted for good rather than trashed, files dropped on the page upload as locked, and "Lock" locks the session again.

  • notifications: /notifications and /admin/notifications (create, test email, template previews), the NotifyAlbumInvite, NotifyAlbumUpdate, NotifyUserSignup, SendMail and NotificationsCleanup jobs, and gallery's emails: crates/imora-emails/src/emails.json holds gallery's react email templates rendered with marker values, which crates/imora-emails/src/lib.rs fills the way react would, so the html is gallery's byte for byte, and crates/imora-core/src/smtp.rs sends them like nodemailer. The web app has the notification bell, the email switches in user settings, the email and template sections in system settings, and the welcome email switch.

  • trash: /trash/empty, /trash/restore and /trash/restore/assets, and a /trash page (when the trash is on) with the retention notice, restore all, empty trash, and restoring or deleting a selection. Deleting elsewhere trashes with an undo, or deletes for good after a confirmation when the photo is already trashed or locked, the trash is off, or with shift+delete.

  • stacks: every /stacks route (search, create with gallery's merging of stacks whose primary joins, get, set the primary, delete one or many, remove an asset) and the on_asset_stack_update event. On Photos and Recently added a selection can be stacked (s) or un-stacked. The viewer adds upload to stack, un-stack, keep this and delete others, set as primary and remove from stack, arrow up and down through the stack, and previews a strip item on hover.

  • mobile sync: /sync/stream and /sync/ack with every stream of gallery's SYNC_TYPES_ORDER (users, partners and their photos with backfills, assets, exif, stacks, albums with their users and photos, people, faces, metadata, edits, ocr, memories, and the fork's shared space and library streams), acks, resets and the 30 day full sync.

  • onboarding: /users/me/onboarding, /system-metadata and /system-config/storage-template-options. The web app has the welcome page of a new server, admin registration, the forced password change, sign in with continue links, and onboarding steps for the theme (light, dark or the system's, also in preferences and on shift+t), and for admins the server privacy, storage template and backup steps. A storage template section joins system settings.

  • maintenance: /admin/maintenance switches the server into maintenance mode, where it restarts into gallery's maintenance server (server config, ping, version, status, install check, token login and ending it, through the login url it logs) and back, like gallery's workers restart. The integrity reports (/admin/integrity) list, download, export and resolve what the integrity checks found. The web app has the maintenance screen, the redirect into and out of it, and the admin maintenance and report pages. Restoring a database backup fails like a restore with a bad file, database backups are not planned.

  • GET /users/{id}, and the fork's GET /admin/users/{id}/library-manifest, a paged list of a user's originals with their checksums and albums for exports.

  • map: /map/markers, /map/reverse-geocode (countries named after GeoNames like Immich 3.3, the names already stored renamed at startup) and the fork's filtered /gallery/map/markers (gallery's flat search filters, person tokens included). The web app has a map page with clustered markers, the filter panel, a side timeline for a cluster or the visible area and the position in the url, a mini map in the details panel, and a location picker to set or change where photos were taken. At startup the microservices worker imports gallery's reverse geocoding data (GeoNames cities500 with its admin codes and the Natural Earth countries) from IMORA_GEODATA (/build/geodata by default) like gallery's MapRepository.init, when its geodata-date.txt differs from the last import. The import runs in one transaction, and a missing file or a failure keeps the places already imported.

  • duplicates and utilities: every /duplicates route (groups with gallery's keep suggestion, resolve merging favorites, visibility, rating, location, description, albums and tags into the kept photos, dismissing), and a utilities page with the duplicates review, large files and "manage location".

  • assets: /asset-files (search, get, download, delete), /assets/copy, /assets/jobs, asset metadata (/assets/metadata and /assets/:id/metadata) and the non destructive editor (/assets/:id/edits with gallery's validation, and its AssetEditThumbnailGeneration job for crops, turns, mirrors and video trims, byte for byte like gallery's, then AssetEditReadyV2). Face and text boxes follow the edits like utils/transform.ts. The web app has an editor in the viewer: crop with aspect ratios, rotate, flip and trim for videos.

  • real time video: the fork's hls routes (/assets/:id/video/stream/...) answer like gallery's api, and imora runs the transcoding worker side too, ffmpeg per session through gallery's server events on the socket cluster. The web player streams with hls.js when the feature is on.

  • face cleanup: every /admin/face-repair route (the scan job, flagged faces, resolving by moving, keeping, locking, parking or detaching faces, dismissals, resolutions and their undo, owner people) and admin pages for the scan results, guided and manual review, and resolutions. /server/ml-health and gallery's machine learning availability checks come with it.

  • machine learning: the imora-ml binary runs gallery's machine learning api (/ping, /predict) in rust, in place of the fork's python service: clip image and text embeddings (with the nllb language table), face detection and recognition (scrfd and arcface), ocr (pp-ocrv5 detection and recognition), and the fork's pet detection and recognition. It loads the same models from hugging face, runs them through onnx runtime with the same execution providers (openvino, cuda, rocm, cpu) and options, and unloads idle models the same way. The image preprocessing ports what the python code leans on, pillow's resampling, the opencv and numpy routines, openblas's small solves and numpy's float32 exp and sorts, so on 453 previews from the library every answer was byte for byte the one the python service gives on main. The clip, face and ocr models follow Immich 3.3's service, byte for byte on its own onnx runtime: one graph per input shape where a provider compiles fixed shapes (openvino, cuda, tensorrt rtx), faces recognized four at a time and text lines six at a time, ocr widths and canvases from Immich's buckets, denormals flushed, Immich's optimizer exclusions, four threads for text search, models that load side by side, preloaded models kept, memory handed back after five idle seconds, and the TensorRT-RTX plugin first when its library sits beside onnx runtime. It always runs Immich's v2 model exports, Gallery's pet models aside, which only exist as main: crates/imora-onnx ports the runtime half of Immich's immich-model library, the fp16 derivation every provider but the cpu gets and each provider's graph rewrites, on the onnx protobuf itself, once per model in a child process. Its graphs are bit for bit those of immich-model on 16 model towers and 6 providers, see docs/benchmarks.md. The admin system settings have gallery's machine learning, classification, video, image, job, logging, map and the other sections.

  • realtime: a websocket only socket.io 4 server at /api/socket.io like gallery's gateway (engine.io errors, cors, the client bundles, heartbeats, error: unauthorized for bad credentials), sending gallery's events from the same actions: on_server_version and on_new_release on connect, on_session_delete, on_asset_trash, on_asset_restore, on_album_update, on_notification and on_config_update. Imora joins gallery's @socket.io/redis-adapter cluster in its redis (crates/imora-core/src/socket/cluster.rs), so events from gallery's workers (finished uploads, portraits, metadata) reach imora's clients, imora's reach gallery's, and cluster requests are answered like a node would, AppRestart included. Without REDIS_* variables events stay on the one server. The web app applies timeline changes live (2.5 second batches like gallery), refreshes the open photo, new portraits and the admin user list, signs out when its session is deleted elsewhere, shows the server status and version in the sidebar, announces new releases to admins and waits out restarts.

  • jobs: a bullmq compatible queue client and worker (crates/imora-bullmq, crates/imora-core/src/jobs/, handlers in crates/imora-jobs) with gallery's queues, job names, options and follow ups, so imora and gallery can work each other's jobs. The ported jobs are thumbnails (libvips loaded at runtime, following sharp's pipeline, and ffmpeg for videos), metadata extraction (exiftool with exiftool-vendored's parsing and time zone rules), sidecar check and write, smart search, duplicate detection, face detection, facial recognition, the people identity backfill and suggestion scans, OCR, pet detection and recognition, auto classification, video transcoding (software, NVENC, QSV, VAAPI and RKMPP), the storage template migration, file migration, and the trash (AssetEmptyTrash, AssetDeleteCheck, AssetDelete with its stack, live photo and file cleanup), and memory generation and cleanup. crates/imora-jobs/src/nightly.rs queues gallery's nightly jobs at the configured start time when no gallery api holds its advisory lock, imora skips the ones it has no handler for. The user purge (UserDeleteCheck, UserDelete) removes deleted users after the configured delay, or right away when deleted with force, and tells clients with on_user_delete. The integrity checks (crates/imora-admin/src/integrity.rs) report untracked, missing and changed files on their configured cron schedules, and the admin maintenance page shows and resolves the reports. External libraries and backups are not ported. Like gallery, imora needs its redis (REDIS_*, redis:6379 by default) and always queues the jobs gallery's api queues. IMORA_WORKERS_INCLUDE and IMORA_WORKERS_EXCLUDE, Gallery's IMMICH_WORKERS_*, pick which of its two workers the process runs: api serves http and websockets, microservices works the queues, both by default. The queues are gallery's, under imora's prefix (imora_bull): an immich or gallery install's immich_bull keys are renamed on the first start, so its pending work carries over. Production runs both workers.

API compatibility

docs/comparison.md lists every Gallery API operation and every user facing Gallery web feature, each marked as done, partial or missing in Imora. Features Imora will not implement (external libraries, database backups, shared spaces, workflows, Takeout and iCloud imports, licensing, folders, tags, partners, user groups and the Android APK links) are kept apart under "Not planned" at its end.

Clients detect Imora from GET /api/server/version, where Imora adds product: "imora" and features, the optional api areas it implements, after Gallery's fields. A client gates on that list rather than on a version.

Imora answers like Noodle Gallery 5.7.0 on the routes it implements, except that it reports its own version (0.0.0), checks for releases at https://code.vexcited.com/imora/imora, and uses its own names where Gallery uses Immich's: the x-imora-* headers, the imora_* cookies, /.well-known/imora and the app.imora:///oauth-callback mobile redirect. Status codes, bodies, validation errors (zod issues), json syntax errors (v8 messages), ETags, the helmet and correlation id headers and the express 404 page are Gallery's. Tokens, API keys, PIN codes and passwords are stored the same way, so a database taken over from Gallery keeps its sessions and keys. The official Immich and Gallery apps do not connect to Imora, its own apps do. This was checked against Gallery 5.7.0 by replaying full auth flows, single requests, every job and the socket traffic on both servers sharing one database, and diffing the answers, the rows and the files left behind, see docs/comparison.md. What stays in the repository are the recorded answers the tests replay: crates/imora-media/src/ffmpeg-cases.json.gz holds 3000 ffmpeg commands and errors from gallery's own builder, and crates/imora-core/tests/v8_json_errors.json the JSON.parse messages of node.

Development

Requirements: Rust nightly, NASM (libjpeg-turbo is built without its simd code otherwise), Bun, Node 22.12+ (Vite), PostgreSQL with pgvector or VectorChord, and Redis for the job queues and the socket cluster.

cp .env.example .env       # then point DB_URL and REDIS_* at your database and redis
cargo server               # api on http://localhost:2284
cargo ml                   # optional, machine learning on http://localhost:3003
cd web && bun run dev      # web app on http://localhost:5173, proxies /api
cargo test                 # unit tests
cd web && bun run build    # type check and build

The server serves the web app it was built with: web/dist is compiled into the binary, so build the web app before a release build. IMORA_WEB_ROOT serves another folder instead. The server listens on 2283 unless IMORA_PORT says otherwise, .env.example moves it to 2284 for development.

Imora never migrates the database: the schema belongs to Immich. On a database with no tables it creates the one Gallery 5.7.0 creates on its first start, migration rows included, so Gallery accepts it later: Gallery's own database after its migrations, dumped once with pgvector and once with VectorChord into crates/imora-core/src/db/bootstrap/. Imora picks the dump like Gallery picks its extension (DB_VECTOR_EXTENSION, else VectorChord before pgvector), under Gallery's migration lock. A database with tables is left as it is. Queries go through tokio-postgres with a deadpool-postgres pool, each statement prepared once per connection. The auth and settings rows are plain structs with their own sql (crates/imora-core/src/db/models.rs), everything else is sql written after Gallery's queries.

Deployment

Production is the Proxmox container pct 106 (named immich), installed like proxmox/ does it, so its update command installs the latest release like any community-scripts container. Releases deploy by themselves: Forgejo's action_run_success webhook of a release run reaches the container's webhook server, whose hook in /srv/hooks runs update. deploy/deploy.sh puts a build of this checkout there in between releases: it cross compiles both binaries with cargo zigbuild and swaps /opt/imora/bin, until the next update. The container is laid out like the Proxmox installer does it: the imora user, /opt/imora/.env, the media in /opt/imora/upload, the model cache in /opt/imora/cache, onnx runtime with OpenVINO in /opt/imora/lib/onnxruntime and exiftool 13.59 in /opt/imora/lib/exiftool. libvips and its codecs are in /usr/local/lib and jellyfin's ffmpeg in /usr/lib/jellyfin-ffmpeg. Caddy (pct 101) serves it at https://photos.vexcited.com.

Machine learning on another machine

The server only talks to imora-ml over http, through the urls in System settings > Machine learning, so the service can run on any machine with a faster cpu or a gpu. Build it with cargo build --release -p imora-ml, then run it with the same variables gallery's python service reads:

Variable Default Meaning
IMORA_ML_HOST, IMORA_ML_PORT [::], 3003 address to listen on, IMMICH_HOST and IMMICH_PORT in Gallery's service
MACHINE_LEARNING_CACHE_FOLDER ~/.cache/imora_ml where models are downloaded
IMORA_ONNXRUNTIME_PATH libonnxruntime.so (.dylib on macos) the onnx runtime library, a build with the execution providers wanted (openvino, cuda, rocm)
IMORA_TENSORRT_RTX_PATH beside onnx runtime the onnxruntime-ep-nv-tensorrt-rtx plugin, used before cuda on ampere and newer gpus
MACHINE_LEARNING_OPENVINO_PRECISION the device's own the precision openvino runs at, imora's own; the pet models run at FP32
MACHINE_LEARNING_* gallery's the rest of gallery's settings: idle unloading, preloading, threads and device

Then add its url (for example http://gpu-box:3003) in System settings > Machine learning. Imora pings every url, sends requests to the first healthy one and falls back to the next, so the local service can stay listed after it.

Releases

npx --@mentor:registry=https://code.vexcited.com/api/packages/mentor/npm/ @mentor/release

(@mentor/release lives on code.vexcited.com's npm registry, not npmjs.org) bumps the version of the workspace in Cargo.toml and Cargo.lock, commits chore: release vX.Y.Z, tags it X.Y.Z and pushes both. Pushing the tag runs .forgejo/workflows/release.yml on the ubuntu-amd64 and ubuntu-arm64 runners: it creates the release, attaches imora-X.Y.Z-linux-<amd64|arm64>.tar.gz with its .sha256, and pushes the images to code.vexcited.com/imora. The images are built from docker/ around the binaries of the same run. Pushing packages needs the REGISTRY_USERNAME and REGISTRY_TOKEN secrets, a Forgejo user and one of its tokens with the write:package scope.

A successful release run then deploys production: Forgejo sends its action_run_success webhook, signed with a shared secret, to the webhook server of the production container, and its hook runs update there when the run's tag is the latest release.