immich-app/immich
github.com/immich-app/immich ↗ · 3129 files · 25.3 MB
High performance self-hosted photo and video management solution Català Español Français Italiano 日本語 한국어 Deutsch Nederlands Türkçe 简体中文 正體中文 Українська Русский Български Português Brasileiro Svenska العربية Tiếng Việt ภาษาไทย മലയാളം [!WARNING] ⚠️ Always follo
Overview
Immich is a self-hosted photo and video backup/management platform (Google Photos alternative) structured as a pnpm+mise monorepo. A NestJS/TypeScript server exposes a REST API and BullMQ background workers on Postgres (pgvector/VectorChord) and Redis, a SvelteKit web app and a Flutter mobile app consume a generated OpenAPI SDK, and a separate Python/FastAPI 'machine-learning' microservice runs ONNX models for CLIP search, facial recognition, and OCR. The stock deployment is four docker-compose services: immich-server (port 2283), immich-machine-learning, valkey (Redis), and a pinned Postgres image with vector extensions.
System map
The system is a multi-process Node.js backend plus three satellite frontends/services. A supervisor in server/src/main.ts forks an 'api' child process and spawns 'microservices'/'maintenance' worker_threads (ImmichWorker enum), each booting a different Nest module from app.module.ts. The API worker serves REST under /api, static web assets via sirv, and socket.io websockets; the microservices worker runs BullMQ consumers over Redis. Persistence is Postgres accessed through Kysely with a hand-maintained schema (server/src/schema) and generated migrations. A Python FastAPI service (machine-learning/) performs inference, and web/ (SvelteKit) plus mobile/ (Flutter) are the user-facing clients built against a generated SDK in packages/sdk.
- server/src/main.ts is the process supervisor: Workers.bootstrap() checks maintenance mode, waits on a Postgres advisory lock (DatabaseLock.MaintenanceOperation via pg_try_advisory_lock), then starts each enabled worker — the API worker via child_process.fork and other workers via worker_threads.Worker.
- Three runtime worker types exist: ImmichWorker.Api, ImmichWorker.Maintenance, and ImmichWorker.Microservices; the set to run is configurable via IMMICH_WORKERS_INCLUDE/IMMICH_WORKERS_EXCLUDE env vars parsed in EnvSchema.
- app.module.ts defines four Nest modules: ApiModule (controllers + AuthGuard + FileUploadInterceptor), MicroservicesModule (BullMQ + SchedulerRegistry), MaintenanceModule (minimal providers + MaintenanceAuthGuard), and ImmichAdminModule (nest-commander CLI commands).
- configureExpress() in app.common.ts wires the HTTP stack: trust proxy settings, helmet from helmet.json, cookieParser, 10mb JSON/urlencoded limits, global 'api' prefix with excludePaths, a custom socket.io WebSocketAdapter, swagger, sirv static serving of the built web app, SSR fallback, compression, and a 24-hour requestTimeout.
- Postgres is accessed through Kysely (kysely-postgres-js driver); the schema is declared in code under server/src/schema/tables and migrations live in server/src/schema/migrations managed by @immich/sql-tools CLI scripts (migrations:generate/run/revert).
- Redis (valkey image in compose) backs BullMQ queues and the socket.io pub/sub adapter; ConfigRepository.getEnv() builds bull.config, bull.queues, and redis options from environment.
- The machine-learning service is a standalone FastAPI app (immich_ml/main.py) exposing GET /ping and POST /predict; it keeps an in-memory ModelCache of ONNX models, runs inference on a ThreadPoolExecutor, and can idle-shutdown after model_ttl seconds.
- The web frontend is SvelteKit + Vite + Tailwind 4; in dev it proxies /api, /.well-known/immich, and /custom.css to IMMICH_SERVER_URL (default http://immich-server:2283), and in production the API server serves the static build via sirv.
- The mobile app is Flutter/Dart (pubspec sdk '>=3.12.0 <4.0.0', flutter 3.47.2) using riverpod, auto_route, drift sqlite, and a generated openapi package at mobile/generated/openapi.
- Standard deployment is docker/docker-compose.yml with services immich-server (port 2283, mounts UPLOAD_LOCATION at /data), immich-machine-learning (model-cache volume), valkey redis, and Postgres 14 image 'postgres:14-vectorchord0.4.3-pgvectors0.2.0'.
- The workspace is orchestrated by pnpm (pnpm-workspace.yaml) and mise (mise.toml pins node 24.21.0, pnpm 11.24.0, java, jellyfin-ffmpeg, extism/binaryen toolchains); server scripts are nest build / vitest, web scripts are vite build / vitest.
- The OpenAPI spec open-api/immich-openapi-specs.json is generated by booting ApiModule in preview mode and writing the swagger document (server/src/bin/sync-open-api.ts); mise task open-api-typescript runs oazapfts to regenerate packages/sdk/src/fetch-client.ts used by web, cli, and e2e.
Feature catalog
Feature surface is primarily ~45 Nest controllers registered in controllers/index.ts covering assets, albums, sharing, people/faces, search, libraries, jobs, sync, auth, and admin management. Background processing is expressed as JobName jobs on QueueName queues (thumbnails, metadata, video conversion, face detection/recognition, smart search, OCR, duplicates, library scan, backups, notifications, workflows). Auth supports password login, OAuth/OIDC with backchannel logout, API keys, sessions, PIN codes, and shared-link access. A WASM plugin system (extism) plus user-defined workflows are newer extension-oriented features.
- Asset upload/download/thumbnails: AssetMediaController exposes POST /assets (multipart, Permission.AssetUpload, sharedLink allowed, optional x-immich-checksum sha1 dedupe header), GET /assets/:id/original, and GET /assets/:id/thumbnail.
- Albums, partner sharing, shared links, stacks, and activities each have dedicated controllers and services (album, partner, shared-link, stack, activity) wired through controllers/index.ts.
- Search features split into metadata search and smart (CLIP) search via SearchController + SearchRepository backed by pgvector/VectorChord embeddings produced by the ML service.
- People/facial recognition is a job pipeline: JobName.AssetDetectFaces and FacialRecognition run on QueueName.FaceDetection/FacialRecognition and call the ML service's facial-recognition detection+recognition pipeline.
- Video features include transcoding (fluent-ffmpeg in media.repository.ts, transcoding.service.ts) and HLS session-based streaming coordinated through Hls* events in the EventMap and hls.service.ts.
- External libraries support filesystem watching via chokidar and scanning jobs (LibrarySyncAssets/LibrarySyncFiles/LibraryScanQueueAll); watching is locked to a single microservice instance via a database lock.
- Auth features: password login with bcrypt + constant-time dummy-hash compare, OAuth/OIDC with backchannel logout token validation, API keys, sessions with lock/invalidate, and PIN code setup/reset/change.
- Admin surface: user-admin.controller, system-config.controller, job.controller/queue.controller for pausing/resuming/cleaning BullMQ queues, database-backup.controller, integrity-admin.controller, and notification-admin.controller.
- Delta sync for offline-capable clients is implemented by sync.controller.ts + sync.service.ts + sync-checkpoint.repository.ts (SyncCheckpointRepository), which mobile uses to reconcile local drift DB state.
- Notifications and email: notification.service.ts + email.repository.ts render react-email templates from server/src/emails and send via nodemailer; jobs like NotifyUserSignup/NotifyAlbumInvite/NotifyAlbumUpdate run on the notifications queue.
- A WASM plugin system loads plugins through @extism/extism with a generic-pool of plugin instances in PluginRepository; exposure is gated by env vars IMMICH_PLUGINS_INSTALL_FOLDER and IMMICH_ALLOW_EXTERNAL_PLUGINS, with a @immich/plugin-sdk workspace package for authors.
- Workflows (user automation) exist end-to-end: workflow.controller.ts, workflow.repository.ts, utils/workflow.ts (isMethodCompatible for plugin methods), QueueName.Workflow, and a web /workflows route.
Data flow
Data enters through Express controllers guarded by a global AuthGuard that produces an AuthDto (user, session, shared link, or API key context) via AuthService.authenticate. Uploads stream through a custom multer storage engine into StorageCore-managed folders under IMMICH_MEDIA_LOCATION, then asset rows are written through repositories over Kysely and BullMQ jobs drive metadata extraction (exiftool), thumbnail generation (sharp), transcoding (ffmpeg), and ML inference via the FastAPI service. All cross-service coordination goes through a typed EventRepository (emit/onEvent) and websocket pushes to clients; mobile/web pull changes via the sync endpoints and socket.io.
- Every HTTP request passes the global AuthGuard: @Authenticated metadata (public/setup/admin/sharedLink/permission) is read via Reflector, then AuthService.authenticate resolves credentials from headers (Authorization bearer, x-api-key), cookies (ImmichCookie), or query params (shared-link key/slug) into request.user.
- Upload path: FileUploadInterceptor runs a custom multer storage (_handleFile/_removeFile) that writes assetData/sidecarData fields to a per-user upload folder computed by AssetMediaService.getUploadFolder (StorageCore.getNestedFolder(StorageFolder.Upload, userId, uuid)) with sanitized uuid filenames.
- After a successful upload, AssetMediaService.uploadAsset creates the asset row (sha1 checksum column for dedupe), upserts sidecar/exif records, queues JobName.AssetExtractMetadata on the metadataExtraction queue, and emits the AssetCreate event; a Postgres checksum-constraint violation is converted into a DUPLICATE response.
- Job transport is BullMQ: services call jobRepository.queue/queueAll (Redis), the microservices worker's per-queue bullmq Worker re-emits each job as a 'JobRun' event, and EventRepository dispatches to the method decorated with matching @OnJob({name, queue}).
- Metadata extraction (MetadataService.handleMetadataExtraction) reads the asset, calls exiftool via media/metadata repositories, reverse-geocodes GPS through mapRepository when enabled, upserts exif/tags, and emits AssetMetadataExtracted which downstream services use to queue thumbnail/smart-search/face jobs.
- ML inference flows server→HTTP: MachineLearningRepository builds typed pipeline requests (ClipVisualRequest, FacialRecognitionRequest, OcrRequest) and POSTs multipart to the FastAPI /predict endpoint, where get_entries parses the pipeline and ModelCache loads ONNX models.
- Database access is repository-per-domain (asset.repository, user.repository, ~55 repositories in repositories/index.ts) over a shared KyselyModule connection; services never hold a Kysely handle directly and reach repos through BaseService.
- System configuration is a JSON document stored in Postgres (system_metadata/system_config), loaded through utils/config.ts getConfig/updateConfig, and broadcast via ConfigInit/ConfigValidate/ConfigUpdate events that services consume with @OnEvent (e.g., QueueService.updateConcurrency).
- Realtime output uses socket.io: WebSocketAdapter attaches a @socket.io/redis-adapter pub/sub pair so API and microservices processes can both emit; WebsocketRepository.setAuthFn authenticates socket handshakes through AuthService.authenticate.
- Storage layout is owned by StorageCore: StorageFolder-based paths (upload, library, thumbs/thumbnails, encoded-video, profile, backups) under IMMICH_MEDIA_LOCATION, with file moves tracked through move.repository so DB paths stay consistent.
- API contract flows outward: Nest swagger decorators (@Endpoint, @Authenticated, DTO zod schemas) generate immich-openapi-specs.json, which oazapfts turns into packages/sdk fetch-client consumed by web (@immich/sdk), @immich/cli, e2e, and the mobile generated openapi package.
- Mobile state sync is pull-based delta sync: sync.controller exposes stream/checkpoint endpoints backed by SyncRepository + SyncCheckpointRepository so the Flutter app's drift sqlite database can incrementally reconcile server state. (inferred)
Behavior traces
Key flows were walked end-to-end. Startup goes through a supervisor that gates on a Postgres advisory lock and maintenance mode, then each worker boots its Nest module which emits AppBootstrap to ordered handlers (database migrations first, then storage, then job discovery). The upload flow traverses guard → multer interceptors → controller → service → DB + queue. Background work is decoupled: jobs land in Redis and are executed by the microservices worker via a JobRun event indirection.
- Startup trace: main() reads argv → Workers.bootstrap() → isMaintenanceMode() (SystemMetadataKey.MaintenanceMode, tolerates missing table via PostgresError 42P01) → waitForFreeLock() (pg_try_advisory_lock loop, 1s poll) → startWorker() per configured ImmichWorker.
- API worker boot trace: workers/api.ts bootstrap() → configureTelemetry() → NestFactory.create(ApiModule, {routeConflictPolicy duplicate:error, routeResolutionStrategy specificity}) → AppRepository.setCloseFn → configureExpress(app, {ssr: ApiService}) which installs middleware, sirv, swagger, and listens on port 2283.
- Nest module init trace: BaseModule.onModuleInit → telemetryRepository.setup → queueService.setServices(services) → websocketRepository.setAuthFn(authenticate) → eventRepository.setup({services}) → emit('AppBootstrap'); onModuleDestroy emits AppShutdown and tears down telemetry.
- Bootstrap ordering is priority-driven: DatabaseService.onBootstrap (priority -200) checks Postgres version range, manages the vector extension (create/update/drop, reindexVectorsIfNeeded), and runs migrations under DatabaseLock.Migrations unless DB_SKIP_MIGRATIONS; StorageService (-195) and JobService (-190) follow, SystemConfig at +100, PluginSync/PluginLoad at +190/+200.
- Job-handler discovery trace: QueueService.onBootstrap (AppBootstrap listener) → jobRepository.setup(services) reflects over every service method, registers those with @OnJob metadata into handlers{}, and throws ImmichStartupError if any JobName value lacks a handler or two handlers claim one job; microservices then startWorkers() and the api worker starts watchWorkers() (30s liveness poll).
- Upload trace: POST /api/assets → AuthGuard.canActivate (Permission.AssetUpload, sharedLink ok) → AssetUploadInterceptor/FileUploadInterceptor multer writes file → AssetMediaController.uploadAsset → service.uploadAsset → requireAccess + requireQuota → onBeforeLink for livePhotoVideoId → assetRepository.create → upsertMetadata/upsertFile/upsertExif → jobRepository.queue(AssetExtractMetadata) → eventRepository.emit(AssetCreate) → 201 or 200-on-duplicate.
- Metadata extraction trace: microservices bullmq Worker dequeues → eventRepository.emit('JobRun', queueName, job) → JobRepository.run dispatches → MetadataService.handleMetadataExtraction → getExifTags (exiftool) + stat → getDates/getImageDimensions → reverseGeocode if GPS → upsert → downstream AssetMetadataExtracted event queues thumbnails, smart search, and face detection.
- Login trace: POST /auth/login → AuthService.login → getConfig(passwordLogin.enabled gate) → userRepository.getByEmail(withPassword) → cryptoRepository.compareBcrypt against user password or LOGIN_DUMMY_HASH for constant-time behavior → createLoginResponse(user, details) issues session + cookies.
- Nightly tasks trace: on ConfigInit the non-microservices worker takes DatabaseLock.NightlyJobs, converts config.nightlyTasks.startTime to a cron expression, and registers CronJob.NightlyJobs whose onTick calls handleNightlyJobs(); ConfigUpdate re-schedules it.
- Worker crash/restart trace: any worker 'exit' triggers onExit; exitCode ExitCode.AppRestart sets restarting=true and reboots all workers once drained; a non-zero exit otherwise kills the api worker (SIGTERM) if present and exits the supervisor process with the same code.
- Websocket connect trace: socket.io handshake → WebSocketAdapter (redis pub/sub adapter) → authFn registered in BaseModule → AuthService.authenticate with uri '/api/socket.io' → WebsocketConnect event emitted with userId.
- ML predict trace: POST /predict → get_entries parses multipart 'entries' JSON into InferenceEntry list splitting with/without model deps → run_inference resolves deps (detection output feeds recognition) → model_cache.get + load() (ONNX fallback if other format unsupported) → model.predict in thread pool → ORJSONResponse.
Testing signals
Testing is layered: Vitest unit tests co-located as *.spec.ts in server/src and web/src, a 'medium' tier in server/test/medium that runs against a real Postgres via testcontainers, API-level e2e specs in e2e/ (Vitest + supertest-style SDK calls against a docker-compose stack), and Playwright browser/UI/maintenance projects. CI (.github/workflows/test.yml) shards jobs per changed directory. Coverage is configured but explicitly excludes some files, and several infra-heavy areas (storage.core is covered, but websocket/adapter and plugin internals have thin coverage).
- Server unit tests run with vitest config test/vitest.config.mjs: name 'server:unit', globals on, include 'src/**/*.spec.ts', swc transform, v8 coverage scoped to src/cores, src/services, src/utils, src/sql-tools.
- A separate medium tier (test/vitest.config.medium.mjs, 'pnpm test:medium') runs integration-style specs in server/test/medium/specs (exif, repositories, services, sync, workflow) with a globalSetup.ts, using testcontainers (devDependency) for real Postgres.
- Tests use a factory/stub pattern: server/test/factories/*.factory.ts (asset, auth, user, session, shared-link, person, etc.) plus server/test/fixtures/*.stub.ts and small.factory.ts/medium.factory.ts builders.
- Most controllers and services have co-located .spec.ts files (e.g., asset-media.controller.spec.ts, auth.service.spec.ts, queue.service.spec.ts) — the dominant convention is one spec per source file.
- e2e/ runs Vitest API specs (src/specs/{server,web,maintenance} covering api, cli, immich-admin) and three Playwright projects (web, ui, maintenance) driven by playwright.config.ts against a docker-compose stack (e2e/docker-compose.yml).
- Web unit tests run via vitest inside vite.config.ts: name 'web:unit', happy-dom environment, svelteTesting plugin, setup file src/test-data/setup.ts, TZ=UTC.
- CI test.yml gates on a pre-job change filter and runs: server unit+lint (mise //server:ci-unit), server medium tests, web lint+unit, cli unit on linux+windows, e2e server-cli and e2e web matrices on ubuntu amd64+arm64 with docker compose up and artifacted docker logs/playwright reports.
- check-openapi.yml enforces that the generated spec is in sync with the code and migration-order.yml verifies migration ordering — both are dedicated CI workflows.
- machine-learning has pytest tests (test_main.py, conftest.py) and a locustfile.py for load testing the predict endpoint.
- Mobile has a Flutter test/ tree and integration_test/ directory; mobile CI is exercised through build-mobile.yml rather than the main test workflow. (inferred)
- Coverage config explicitly excludes api.service.ts and microservices.service.ts from instrumentation, and files like websocket.adapter.ts, plugin.service.ts, and several controllers have no co-located spec — thin spots in otherwise high coverage.
Risk map
Security-sensitive surface concentrates on file upload/disk IO, auth token handling, subprocess media tooling, and the WASM plugin host. The multer storage engine writes user-supplied bytes to predictable uuid paths, ffmpeg/exiftool process untrusted media, and extism plugins run third-party WASM gated by env vars. Operational risks include a 24h request timeout, trust-proxy assumptions, startup-time migrations under an advisory lock, and a supervisor that kills the whole process on worker failure. Several safety rails exist (mandatory @Authenticated, duplicate-job startup validation, constant-time login compare) but are themselves fragile coupling points.
- File upload writes untrusted bytes through a custom multer _handleFile to disk paths derived from StorageCore folders; filenames are sanitized via sanitize-filename and uuid-based, but the interceptor runs before controller validation, so malformed multipart payloads hit the FS layer early.
- express 'trust proxy' is set to ['loopback', ...network.trustedProxies] from IMMICH_TRUSTED_PROXIES; clientIp used in login logging/GetLoginDetails depends on this being correct — misconfiguration enables IP spoofing in audit logs and rate-sensitive paths.
- server.requestTimeout is set to 24 hours (24*60*60*1000) to support huge uploads/downloads; combined with 10mb JSON body limits this leaves long-lived socket exposure on the API port.
- Password auth uses bcrypt compare against LOGIN_DUMMY_HASH when the email is unknown (constant-time anti-enumeration) — the dummy hash constant lives in constants.ts and weakening/removing it would reopen user enumeration.
- Shared-link access is authorized via query parameters (key/slug) declared with ApiQuery in the @Authenticated decorator, meaning bearer-equivalent secrets can appear in URLs, access logs, and referrers. (inferred)
- Media processing shells out to ffmpeg/ffprobe (fluent-ffmpeg) and exiftool-vendored on user-uploaded files; media.repository.ts builds ffmpeg calls with niceness 10 — a large blast radius if argument handling or the bundled jellyfin-ffmpeg is compromised.
- The plugin host instantiates extism WASM plugins pooled via generic-pool and calls plugin methods by name; external plugin installation is env-gated (IMMICH_ALLOW_EXTERNAL_PLUGINS) and mis-set values expand supply-chain surface.
- Startup is stateful and lock-coupled: the supervisor busy-polls pg_try_advisory_lock(MaintenanceOperation), DatabaseService runs migrations under DatabaseLock.Migrations, and nightly jobs require DatabaseLock.NightlyJobs — stuck locks or split-brain DBs can wedge boot or scheduling.
- Any non-zero worker exit kills the API child (SIGTERM) and exits the supervisor with that code — a crash in a background job can take down the whole API unless orchestrated restart policies compensate.
- Every route must declare @Authenticated() or AuthGuard throws at request time ('Route X does not declare @Authenticated()'); this is a strong default-deny but means a missing decorator yields runtime 500s rather than compile errors.
- Library watching uses chokidar over admin-configured filesystem paths and writes discovered files into the managed tree; a hostile or malformed library path expands file-IO reach beyond the media root.
- Dev/test convenience defaults embed credentials: DB_URL defaults to postgres://postgres:postgres@localhost:5432/immich in migration scripts and sync-open-api.ts hardcodes the same DSN — fine for local dev but a footgun if reused.
Extension points
The codebase is organized around explicit registries: controllers/index.ts, services/index.ts, repositories/index.ts, commands/index.ts, plus decorator-driven registration (@OnJob, @OnEvent, @Endpoint, @Authenticated). Adding an endpoint means a controller method + zod DTO + index registration; adding a job means enum entries + @OnJob handler (enforced at startup). A WASM plugin system via @immich/plugin-sdk and workflows provide runtime extensibility, and the OpenAPI→SDK codegen pipeline propagates API changes to all clients.
- New HTTP endpoints plug in via a controller class added to the controllers array in controllers/index.ts; the ApiModule mounts every entry under the global /api prefix.
- New background jobs require: a JobName enum member, a QueueName choice, and a method decorated @OnJob({name, queue}) on a service — jobRepository.setup validates complete coverage at boot and throws ImmichStartupError for missing or duplicate handlers.
- New domain events plug in via the typed EventMap in event.repository.ts plus @OnEvent({name, priority, server, workers}) on service methods; BootstrapEventPriority documents ordering constraints for bootstrap handlers.
- New services extend BaseService (which injects all ~55 repositories) and must be added to services/index.ts so queueService.setServices and eventRepository.setup can discover their decorated methods.
- New repositories are classes added to repositories/index.ts and BASE_SERVICE_DEPENDENCIES so every service receives them positionally through BaseService's constructor.
- WASM plugins are a first-class extension point: authors build against @immich/plugin-sdk (host-functions.ts, sdk.ts), plugins install under IMMICH_PLUGINS_INSTALL_FOLDER, are pooled/executed by PluginRepository via extism, and their methods/templates surface through plugin.controller and workflow compatibility checks.
- Admin CLI commands plug in via nest-commander Command classes registered in commands/index.ts (commandsAndQuestions) and run under ImmichAdminModule when invoked as 'immich-admin'.
- API versioning/documentation is decorator-driven: @Endpoint({summary, history: new HistoryBuilder().added('vX').beta('vY')}) emits custom swagger extensions (x-immich-history/state) that feed the generated spec — deprecations flow automatically via ApiTag.Deprecated.
- Request/response validation is nestjs-zod: DTOs are zod schemas (e.g., EnvSchema, QueueNameSchema) validated globally by ZodValidationPipe and serialized by ZodSerializerInterceptor registered as APP_PIPE/APP_INTERCEPTOR.
- DB schema changes plug in through sql-tools: table classes in server/src/schema/tables with Column/trigger decorators (PrimaryGeneratedUuidV7Column, UpdatedAtTrigger), functions in schema/functions.ts, then 'pnpm migrations:generate' diffs into schema/migrations with an ORDER file.
- Client surface extends via codegen: edit DTOs/controllers → run sync-open-api.ts to refresh immich-openapi-specs.json → mise task open-api-typescript regenerates packages/sdk fetch-client used by web/cli/e2e; mobile consumes a generated dart openapi package.
- Web features plug in as SvelteKit routes under web/src/routes/(user)/... with shared components/stores/managers in web/src/lib, all typed against @immich/sdk.
Contributor handoff
This is a large, convention-heavy monorepo — change one layer and the generated artifacts (OpenAPI spec, SDK, migrations, mobile client) must be regenerated and kept in sync or CI fails. Server code follows strict layering (controller → service → repository → Kysely) with decorator-driven registration that will fail loudly at startup if misconfigured. Tests are fast to run per-package but e2e/medium tiers need Docker. Read the bootstrap and decorator machinery before editing anything structural.
- Read first (server): server/src/main.ts (supervisor), server/src/app.module.ts (module wiring), server/src/enum.ts (all JobName/QueueName/ImmichWorker/Permission values), server/src/decorators.ts (@OnJob/@OnEvent/@Endpoint/@Chunked), server/src/services/base.service.ts (repository injection convention).
- Convention: controllers stay thin — they take @Auth() AuthDto + DTOs and delegate to a service; every handler must declare @Authenticated() (defaults to authenticated; use {public:true} or {sharedLink:true} variants) or the guard throws.
- Convention: authorization inside services goes through this.requireAccess({auth, permission, ids}) / checkAccess backed by access.repository.ts — don't hand-roll ownership checks.
- Adding a job is a startup-validated contract: new JobName without an @OnJob handler (or a duplicate handler) aborts boot with ImmichStartupError — always add enum + decorated method + ensure the service is in services/index.ts.
- API changes must be followed by regeneration: sync-open-api.ts writes the spec, 'mise run open-api-typescript' rebuilds packages/sdk, and the check-openapi CI workflow fails if the committed spec drifts.
- Schema changes: edit table classes under server/src/schema/tables, then 'pnpm migrations:generate' (sql-tools against DB_URL); ordering is enforced by schema/migrations/ORDER and the migration-order.yml workflow — see docs/docs/developer/database-migrations.md.
- Test commands: server unit 'pnpm test' (vitest, no DB needed), 'pnpm test:medium' (needs Docker for testcontainers), e2e 'pnpm test'/'test:web' in e2e/ (needs docker compose up), web 'pnpm test'. Lint gates are strict: eslint --max-warnings 0 plus prettier checks.
- Known trap: services get repositories positionally through BaseService — adding a repository means updating repositories/index.ts AND the BASE_SERVICE_DEPENDENCIES tuple AND the constructor AND BaseService.create in the same order, or DI silently misaligns.
- Known trap: file paths on disk must go through StorageCore/StorageRepository helpers (StorageFolder layout, media location from IMMICH_MEDIA_LOCATION) — never construct storage paths ad hoc, and DB-stored paths are updated via MoveRepository.
- Known trap: long-running requests are normal (24h requestTimeout, streaming uploads/downloads via sendFile/ImmichFileResponse) — avoid buffering whole files in memory and prefer the ImmichFileResponse pattern for binary responses.
- Environment config is centralized: add new env vars to EnvSchema in dtos/env.dto.ts and surface them via ConfigRepository.getEnv(); never read process.env inside services.
- Toolchain is pinned by mise (node 24.21.0, pnpm 11.24.0, java 21 for openapi-generator, extism/binaryen for plugins, jellyfin-ffmpeg for transcoding tests) — use 'mise run //server:ci-unit' style tasks to match CI rather than raw pnpm where possible.
Languages
| Language | Files | Share of source |
|---|---|---|
| TypeScript | 1071 | 64% |
| Svelte | 416 | 25% |
| JSON | 168 | docs/config |
| Markdown | 124 | docs/config |
| YAML | 53 | docs/config |
| SQL | 36 | 2% |
| Kotlin | 35 | 2% |
| Python | 34 | 2% |
Start reading here
Probable source entrypoint: packages/ — inferred from the directory tree and manifest, not the upstream docs.
Source roots
packages/ (62 files)
Repository structure
| Top-level path | Files |
|---|---|
mobile/ | 1121 |
web/ | 739 |
server/ | 728 |
docs/ | 127 |
i18n/ | 89 |
e2e/ | 84 |
packages/ | 62 |
machine-learning/ | 51 |
Docs found: CONTRIBUTING.mdREADME.mddocs/README.mddocs/docs/administration/backup-and-restore.mddocs/docs/administration/jobs-workers.mddocs/docs/administration/maintenance-mode.mddocs/docs/administration/oauth.mddocs/docs/administration/postgres-standalone.mddocs/docs/administration/reverse-proxy.mddocs/docs/administration/server-commands.md
Package
package.json — immich-monorepo v3.2.0, 2 declared dependencies.
prettierprettier-plugin-sort-json
CI & config entrypoints
README.md— Repository readmeweb/vite.config.ts— Vite configpackages/cli/vite.config.ts— Vite configpackages/scripts/vite.config.ts— Vite config.github/workflows/check-openapi.yml— GitHub Actions workflow.github/workflows/docs-deploy.yml— GitHub Actions workflow.github/workflows/auto-close.yml— GitHub Actions workflow.github/workflows/draft-release.yml— GitHub Actions workflow.github/workflows/org-zizmor.yml— GitHub Actions workflow.github/workflows/docs-build.yml— GitHub Actions workflow
Structural health signals
Average score 6.5/10 across 96 analyzed source files; 46 hotspots surfaced.
server/src/schema/migrations/1744910873969-InitialMigration.ts— 612 lines, score 4.5 (Large file, Deep nesting)server/src/repositories/search.repository.ts— 605 lines, score 4.5 (Large file, Deep nesting)server/src/services/workflow-execution.service.ts— 515 lines, score 4.5 (Large file, Deep nesting)
These are deterministic review leads — size, churn, and structural signals — not proof of a bug in this project.
Recent history
d6d0947383662026-09-22 — feat: birthday memories (#30831)f48d4b3321272026-09-21 — fix(mobile): ios clear file cache feedback (#31401)202015ed95dc2026-09-19 — chore(deps): update machine-learning (#29123)69e108adf1aa2026-09-18 — fix(web): queue buttons focus outline (#31657)227ef01718562026-09-18 — fix(web): scrollable search dropdown on mobile web (#31634)