A Docker sidecar service that syncs a subset of Immich photos from one user to another — without duplicating ML processing.
Immich's built-in partner sharing lets you view another user's library, but it's limited: you share your entire library or nothing, and face recognition doesn't work on partner-shared photos — you can't search for or browse by people in your partner's library.
The common workaround is symlinking an external library directory so both users point at the same photos. This gives each user their own copy of the assets with full face recognition support, but Immich processes each user's assets independently through the full ML pipeline:
| Per-asset work | Symlink only | With sidecar |
|---|---|---|
| Metadata extraction (EXIF) | 2x | 1x (copied) |
| Thumbnail generation | 2x | 1x (hardlinked) |
| CLIP embedding (smart search) | 2x (GPU) | 1x (copied) |
| Face detection | 2x (GPU) | 1x (copied) |
| Face recognition | 2x (GPU) | 1x (copied) |
| Person clustering | Independent per user | Mirrored from source |
| Person names | Must name separately | Synced automatically |
With 1,000 shared photos, the symlink approach queues 5,000+ extra ML jobs (metadata, thumbnails, CLIP, face detection, face recognition) that produce identical results. Each user also gets independent person clusters, so you'd need to name each person twice — and the clusters may group faces differently.
This sidecar connects directly to Immich's PostgreSQL database and, for each shared source asset:
- Creates a target asset record with remapped file paths
- Copies EXIF metadata, CLIP embeddings, face detection results, and face recognition data
- Hardlinks thumbnail and preview files (zero extra disk space)
- Creates mirrored person records with hardlinked face thumbnails
- Pre-populates job status so Immich skips all ML processing for these assets
The target user's assets appear instantly with full search, face recognition, and timeline support — no ML queue, no GPU time. The sidecar runs continuously, syncing new assets, propagating person name changes, and cleaning up deletions.
- Docker with
docker compose— the sidecar builds and runs entirely in Docker, no other development tools required - Immich v3.0.1 (tested). Other v3.x versions may work but the database schema can change between releases — check the Immich release notes before upgrading. Note: since v3, album ownership lives in
album_user(roleowner), and the sidecar copies OCR results (asset_ocr/ocr_search) and video stream metadata (asset_video/asset_audio/asset_keyframe) alongside CLIP embeddings. - Two or more Immich users (at least one source and one target)
- Source assets must be fully processed by Immich (metadata, faces, CLIP)
Back up your database before running this sidecar. It writes directly to Immich's PostgreSQL database. If something goes wrong, you'll want a backup to restore from. A simple
pg_dumpis enough:docker exec immich_postgres pg_dump -U postgres immich > immich_backup.sql
The sidecar supports two sync methods. You can use one or both:
| Method | What it syncs | Source |
|---|---|---|
| External Library Sync | A subset (or all) of one user's external library | Source user's external library directory |
| Upload Sync | App/website uploads from a different user | Source user's upload directory (camera roll, web uploads, etc.) |
Alice manages the family photo library in Lightroom Classic and imports the finished edits into Immich via an external library. She uses External Library Sync to share a curated subset of family photos with Bob — just the family shots, not the street photography or landscapes.
Bob uploads all his phone photos through the Immich app. Alice uses Upload Sync to pull Bob's entire upload library into her account. The ML data (CLIP embeddings, face detection, face recognition) is copied along with the assets, so Alice gets full search and face recognition on Bob's photos without any duplicate GPU processing.
Both sync methods run together in the same sidecar, syncing into Alice's and Bob's accounts respectively.
The interactive setup wizard handles everything: connecting to Immich, detecting paths, creating symlinks and libraries, and generating the .env file.
Prerequisites: Create an admin API key in Immich (Account Settings > API Keys) and have python3 installed on the host.
python3 setup.pyThe wizard will:
- Connect to your Immich server and verify admin access
- Auto-detect volume mount paths from Immich's
docker-compose.yml - Let you choose which sync method(s) to configure (external library, upload sync, or both)
- Walk you through selecting source/target users
- Create the necessary symlinks and external libraries in Immich (with
**/*exclusion so Immich won't scan them — you must also disable Immich's periodic scan, see Setup) - Optionally set up album assignment
- Generate a
.envfile with all the correct UUIDs and path prefixes
Once the wizard completes, start the sidecar:
docker compose up -dIf you already have a .env file, re-running the wizard will use your existing values as defaults and let you add or reconfigure sync methods.
For setups with more than one sync job, or for cleaner configuration, create a config.yaml file. This separates per-job settings from infrastructure settings (which stay in .env).
# config.yaml
sync_jobs:
- name: "alice-external-to-bob"
source_user_id: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"
target_user_id: "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"
target_library_id: "llllllll-llll-llll-llll-llllllllllll"
source_path_prefix: "/external_library/alice/photos/"
target_path_prefix: "/external_library/bob_shared/photos/"
album_id: "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" # optional, per-job
- name: "charlie-uploads-to-alice"
source_user_id: "cccccccc-cccc-cccc-cccc-cccccccccccc"
target_user_id: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"
target_library_id: "mmmmmmmm-mmmm-mmmm-mmmm-mmmmmmmmmmmm"
source_path_prefix: "/usr/src/app/upload/library/cccccccc-cccc-cccc-cccc-cccccccccccc/"
target_path_prefix: "/external_library/alice_uploads/charlie/"# .env (infrastructure only)
DB_PASSWORD=postgres
UPLOAD_LOCATION=../immich-app/library
EXTERNAL_LIBRARY_DIR=../immich-app/external_library
IMMICH_API_KEY=your-keyMount the config file in docker-compose.yml by uncommenting the volume line:
volumes:
- ./config.yaml:/app/config.yaml:roThe setup wizard (python3 setup.py) generates both files and enables the volume mount automatically.
Backward compatibility: If no config.yaml exists, the sidecar falls back to per-job environment variables (SOURCE_USER_ID, TARGET_USER_ID, etc.) — existing .env-only deployments continue to work unchanged.
Migrating from env vars to config.yaml: Create a
config.yamlwith your job(s), move album config fromTARGET_ALBUM_IDto per-jobalbum_id, add the volume mount todocker-compose.yml, and remove the per-job env vars from.env. Re-runningpython3 setup.pydoes this automatically.
If the wizard doesn't suit your environment, or you need to understand what each setting does (useful for troubleshooting), follow the manual steps below.
In Immich, go to Account Settings > API Keys and create a key.
cp env.example .envEdit .env with the common settings:
DB_PASSWORD=postgres
# Host paths to Immich's data directories
UPLOAD_LOCATION=../immich-app/library
EXTERNAL_LIBRARY_DIR=../immich-app/external_library
IMMICH_API_KEY=your-immich-api-key
TARGET_USER_ID=yyyyyyyy-yyyy-yyyy-yyyy-yyyyyyyyyyyyUser IDs: In the Immich web UI, go to Administration > Users and click on a username. The UUID appears in the URL: /admin/users/{UUID}.
Library IDs: In the Immich web UI, go to Administration > External Libraries and click on a library. The UUID appears in the URL: /admin/library-management/{UUID}.
Now configure one or both sync methods (external library or upload), then continue to Album Assignment and Start the Sidecar.
Syncs assets from a source user's external library into the target user's account. Use this when both users should see the same set of externally-managed photos (e.g., a shared photo directory on a NAS).
The source user (User A) has an external library that Immich scans and processes normally. You create a second external library for the target user (User B) containing a symlink to the same photos. The sidecar creates asset records for User B directly in the database — Immich never scans this target library.
Create the symlink so the target user's external library points to the source user's photos:
/external_library/
user_a/ # Source user's external library (scanned by Immich)
personal/
photo1.jpg
shared/ # Subdirectory to share with User B
photo2.jpg
photo3.jpg
user_b_shared -> user_a/shared # Target user's external library (NOT scanned — sidecar handles it)
You can symlink the entire source directory or just a subdirectory — the SHARED_PATH_PREFIX controls which source assets are synced.
Create the target external library in Immich for the target user (User B) with an import path that covers the symlink directory (e.g., /external_library/user_b_shared/).
Important: This library must not be scanned by Immich. Add the exclusion pattern **/* to tell Immich to ignore all files:
- Go to Administration > External Libraries
- Click on the target user's new external library
- Add
**/*as an exclusion pattern and save
The sidecar writes asset records directly to the database. Immich sees User B's assets because they exist in the asset table, not because it scanned the filesystem.
Enable library watching in Immich's Administration > Settings > External Library. This uses filesystem events (inotify) to detect new files added to the source user's external library. Immich will process them through the ML pipeline. Once processing completes, the sidecar picks them up on the next sync cycle.
Because the target library has the **/* exclusion pattern, Immich will ignore file events in that library.
⚠️ Disable Immich's periodic scan, and never run a manual scan on a target library.Library watching (inotify) respects the
**/*exclusion and is safe. A scan is not: it crawls the target library, finds 0 files (because of the exclusion), concludes every sidecar-managed asset is missing from disk, and marks them all offline — soft-deleting them so the target user's shared photos disappear. A full library's worth of assets can be offlined in a single scan.To avoid this:
- Turn off Administration > Settings > External Library > Periodic Scanning (or set no cron schedule). Rely on library watching to discover new source files instead.
- Never click Scan on a target library in the UI.
Do not work around this by removing the
**/*exclusion pattern. Without it, Immich imports and runs full ML processing (thumbnails, transcoding, CLIP, faces) on every target file itself — duplicating all the work this sidecar exists to avoid. See issue #3.
Note: The sidecar does not trigger library scans itself. It relies on Immich's library watching (or manual scans) to discover new source files. If library watching doesn't work in your environment (e.g., network drives), you can trigger scans manually:
curl -X POST "http://localhost:2283/api/libraries/SOURCE_LIBRARY_ID/scan" \ -H "x-api-key: YOUR_KEY"
Add to .env:
SOURCE_USER_ID=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
TARGET_LIBRARY_ID=zzzzzzzz-zzzz-zzzz-zzzz-zzzzzzzzzzzz
# Path prefixes as seen inside the Immich container (not on the host)
SHARED_PATH_PREFIX=/external_library/user_a/shared/
TARGET_PATH_PREFIX=/external_library/user_b_shared/Syncs app/website uploads from a source user into the target user's account. Use this when the source user uploads photos via the Immich mobile app or web UI and you want them to appear in the target user's library too.
The source user (User C) uploads photos normally — Immich stores them in its upload directory and runs ML processing. You create an external library for the target user (User B) containing a symlink to User C's upload directory. The sidecar creates asset records for User B directly in the database.
Create the symlink from a directory inside the target user's external library to the source user's upload directory:
/external_library/
user_b_uploads/ # Target user's external library (NOT scanned — sidecar handles it)
user_c -> /data/upload/cccccccc-cccc-cccc-cccc-cccccccccccc/
The symlink target must point to the source user's upload directory inside the container. Immich stores uploads at {upload_location}/upload/{user_id}/.
Create the target external library in Immich for the target user (User B). This must be a separate library from any library used for external library sync.
Important: This library must not be scanned by Immich. Add the exclusion pattern **/*:
- Go to Administration > External Libraries
- Click on the target user's new upload sync library
- Add
**/*as an exclusion pattern and save
Why a separate library? The source user's libraries are actively scanned by Immich to discover and process new assets. The upload sync target library must not be scanned (Immich would create duplicate records and run redundant ML processing). The sidecar handles creating the asset records directly in the database.
⚠️ The same scan warning applies here: disable Immich's periodic scan and never manually scan this library, or the**/*exclusion will cause Immich to offline every synced asset. See the warning in the external library sync setup above and issue #3.
Add to .env:
# Source user whose uploads you want to sync
UPLOAD_SOURCE_USER_ID=cccccccc-cccc-cccc-cccc-cccccccccccc
# The new external library created above (separate from any external library sync library)
UPLOAD_TARGET_LIBRARY_ID=wwwwwwww-wwww-wwww-wwww-wwwwwwwwwwww
# Path prefix where the symlink maps uploads into the external library
TARGET_UPLOAD_PATH_PREFIX=/external_library/user_b_uploads/user_c/The sidecar automatically derives the source path prefix from the upload location mount and the source user ID (e.g., /usr/src/app/upload/upload/cccccccc-cccc-cccc-cccc-cccccccccccc/).
Note: If you're only doing upload sync, you don't need
SOURCE_USER_ID,TARGET_LIBRARY_ID,SHARED_PATH_PREFIX, orTARGET_PATH_PREFIX. Those are only for external library sync.
To have synced assets automatically added to an album in the target user's account, create the album first in Immich, then configure it.
With config.yaml (recommended): Add album_id to each job that needs it. Different jobs can target different albums:
sync_jobs:
- name: "alice-to-bob"
# ...
album_id: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa" # Bob's album
- name: "charlie-to-alice"
# ...
album_id: "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb" # Alice's albumWith env vars (legacy): Add TARGET_ALBUM_ID to .env — this applies the same album to all jobs:
TARGET_ALBUM_ID=aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaaThe album must be owned by the job's target user.
If you add an album after assets have already been synced, the sidecar will backfill all previously synced assets into the album on the next cycle. When a source asset is deleted, its album entry is also removed during cleanup.
The sidecar runs as a standalone compose project that connects to Immich's Docker network. From this directory:
docker compose up -dNetwork configuration: The
docker-compose.ymlassumes Immich's Docker network is namedimmich_default(the default when Immich's compose project is namedimmich). If your Immich setup uses a different network name — for example because of a customCOMPOSE_PROJECT_NAME, a different directory name, or a manually defined network — edit thenetworkssection at the bottom ofdocker-compose.ymlto match:networks: immich: external: true name: your_actual_network_name # e.g., immich-app_defaultYou can find your Immich network name with
docker network ls | grep immich.
The UPLOAD_LOCATION and EXTERNAL_LIBRARY_DIR in .env must point to the same host directories that Immich mounts (typically ./library and ./external_library in your Immich directory). Hardlinks require the same filesystem.
The sidecar will:
- Wait for the Immich server to become available
- Create its tracking tables (
_face_sync_asset_map,_face_sync_person_map) - Run a sync cycle every 60 seconds (configurable via
SYNC_INTERVAL_SECONDS)
When you pull new changes from this repository (for example after an Immich upgrade that requires sidecar schema fixes), rebuild the image and restart the container:
git pull
docker compose up -d --buildThe --build flag is required — without it, Docker will keep using the previously built image and your code changes won't take effect.
Then check the logs to confirm the sidecar is running cleanly and syncing photos:
docker compose logs -fLook for "Schema validation passed" on startup and periodic sync cycle messages. If schema validation fails, the logs will tell you exactly which columns or tables changed — this usually means Immich added new required columns that the sidecar needs to supply.
Each job in sync_jobs supports these fields:
| Field | Required | Description |
|---|---|---|
name |
Yes | Unique name for this sync job |
source_user_id |
Yes | UUID of the source user |
target_user_id |
Yes | UUID of the target user (receives synced copies) |
target_library_id |
Yes | UUID of the target user's external library (with **/* exclusion) |
source_path_prefix |
Yes | Path prefix for source assets inside the container |
target_path_prefix |
Yes | Path prefix for target assets inside the container |
album_id |
No | UUID of album to add synced assets to (must be owned by target user) |
| Variable | Default | Description |
|---|---|---|
UPLOAD_LOCATION |
(required) | Host path to Immich's upload/data directory (e.g., ../immich-app/library) |
EXTERNAL_LIBRARY_DIR |
(required) | Host path to the external library directory (e.g., ../immich-app/external_library) |
DB_PASSWORD |
postgres |
PostgreSQL password (same as Immich) |
DB_USERNAME |
postgres |
PostgreSQL username |
DB_DATABASE_NAME |
immich |
PostgreSQL database name |
IMMICH_API_KEY |
(required) | Immich API key |
SYNC_INTERVAL_SECONDS |
60 |
Seconds between sync cycles |
LOG_LEVEL |
INFO |
Logging level (DEBUG, INFO, WARNING, ERROR) |
When no config.yaml file exists, the sidecar falls back to these per-job env vars:
| Variable | Description |
|---|---|
TARGET_USER_ID |
UUID of the target user |
SOURCE_USER_ID |
UUID of the source user (external library sync) |
TARGET_LIBRARY_ID |
UUID of the target user's external library |
SHARED_PATH_PREFIX |
Source path prefix (external library sync) |
TARGET_PATH_PREFIX |
Target path prefix (external library sync) |
UPLOAD_SOURCE_USER_ID |
UUID of the source user (upload sync) |
UPLOAD_TARGET_LIBRARY_ID |
UUID of a separate external library (upload sync) |
TARGET_UPLOAD_PATH_PREFIX |
Target path prefix (upload sync) |
TARGET_ALBUM_ID |
UUID of album (applies to all jobs) |
At least one of SHARED_PATH_PREFIX or UPLOAD_SOURCE_USER_ID must be set. Both can be configured simultaneously.
Each sync cycle runs five phases:
- New assets — For each configured sync job (external library, uploads), finds fully-processed source assets not yet synced. Creates target asset records with copied EXIF, CLIP embeddings, faces, and hardlinked thumbnails. 1b. Album assignment — Adds newly synced assets to the target album (if configured). Backfills any previously synced assets that are missing from the album.
- Incremental faces — Detects face updates on already-synced assets (using a watermark timestamp) and copies new faces.
- Person metadata — Syncs person name changes, visibility (
isHidden), and thumbnail updates from source to target. - Cleanup — Removes target assets (and their album entries) whose source was deleted or trashed. Detects person merges (face reassignment). Removes orphaned target persons.
When the sidecar syncs an asset, it copies the source user's face detection data (bounding boxes, embeddings) and creates a mirrored person for the target user. The source user's person names, visibility, and thumbnails are propagated to these mirrored persons automatically on every sync cycle. The source user is the authority — if they rename "Mom" to "Mother", the target user's mirrored person updates to match.
If the target user already has their own photos with detected faces, they'll see duplicate person entries: their own person (from their uploads) and the sidecar's mirrored person (from synced assets). To unify them, use Immich's merge feature in the People view. The sidecar detects the merge on the next cycle and adopts the surviving person, so name and visibility sync continue to work regardless of which direction you merge.
force=truejobs: If someone triggers a force re-process in Immich, it will re-run ML on the target user's assets, overwriting the copied data. The sidecar will re-sync on the next cycle, but there will be temporary GPU usage.- Same filesystem required: Hardlinks only work when the sidecar container mounts the same volume as Immich. Cross-filesystem setups would need file copies instead.
- Direct database access: This service writes directly to Immich's database. Tested with v3.0.1 — schema changes in other versions may require updates to this sidecar. Always back up your database before use.
- Single direction: Sync is one-way (source → target). Changes made to target assets in Immich are not propagated back.
This is optional — the sidecar runs entirely in Docker and setup.py uses only the Python standard library. A local venv is only useful for IDE autocomplete, linting, and syntax checking.
python3 -m venv .venv
source .venv/bin/activate
pip install -e .The utility scripts read configuration from .env (the same file used by docker compose). Since the Immich PostgreSQL container doesn't expose port 5432 by default, they must run inside a Docker container on the Immich network. run-utility.sh handles the Docker invocation:
./run-utility.sh test_sync.py
./run-utility.sh dedup_synced.py --match-time
./run-utility.sh delete_synced.pytest_sync.py— Run a single sync cycle and print verification queries.delete_synced.py— Delete all synced assets for a target user. Does not mark sources as skipped, so running the sync engine again will recreate everything. Useful for resetting a target account.dedup_synced.py— Detect and remove synced assets that duplicate the target user's own uploads (matched by filename + capture date). Use--match-timeto compare the full timestamp (with TZ normalisation) instead of just the date. Marks duplicates as skipped so the sync engine won't recreate them.reset.sh— Full reset: stops the sidecar container, deletes all synced assets and mirrored persons from Immich, drops the tracking tables, and removes symlinks from the external library directory. Run directly on the host (not viarun-utility.sh). Shows a summary and prompts for confirmation before making changes.
delete_synced.py and dedup_synced.py are interactive: they show a summary and prompt for confirmation before making changes, with a dry-run option.
-
Run the setup wizard to configure your
.envand connect to a local Immich instance:python3 setup.py
-
Copy some photos into the source user's watched folders or upload them via the Immich app/web UI. Wait for Immich to finish processing (metadata, thumbnails, CLIP, faces).
-
Run a sync cycle to create the target assets:
./run-utility.sh test_sync.py
-
Verify
dedup_synced.pydetects duplicates (if the target user also has copies of the same photos):./run-utility.sh dedup_synced.py
-
Run
delete_synced.pyand confirm it shows the correct number of synced assets:./run-utility.sh delete_synced.py
-
Run
test_sync.pyagain — it will recreate the deleted assets, confirming the full round-trip works.
src/
main.py — Entry point: config validation, health check, concurrent loops
sync_engine.py — Orchestrates 5-phase sync cycle
asset_sync.py — Asset record creation, EXIF copy, path remapping
ml_sync.py — Face and embedding sync
person_sync.py — Person mirroring, name/visibility sync
album_sync.py — Album assignment and backfill
cleanup.py — Deletion detection and cleanup
file_ops.py — Hardlink creation and removal
db.py — asyncpg connection pool and transaction helpers
config.py — SyncJob dataclass, YAML loader, Pydantic Settings (env var fallback)
immich_api.py — Immich REST API client (health check)
health.py — TCP health check server
- Immich tables are singular (
asset, notassets) with camelCase columns that must be double-quoted in SQL. - The sidecar creates two tracking tables prefixed with
_face_sync_to avoid colliding with Immich's schema. - Each asset sync uses a PostgreSQL SAVEPOINT so one failure doesn't roll back the entire batch.
- Cleanup deletes hardlinked files before DB records to avoid orphan files on crash.