Ahmad

Install Immich on TrueNAS with GPU acceleration, and migrate a library without losing faces or albums

Your photos are not the valuable part - every named face, album and person lives in the Postgres database. A complete build with the library on HDD and the database on NVMe, Intel GPU acceleration that is verified rather than assumed, and a migration that keeps everything you built.

advanced75 minupdated 2026-09-13dockerimmichmigrationopenvinophotospostgrestruenasDownload script.sh

Before you start

  • TrueNAS SCALE with an NVMe pool and an HDD pool
  • Docker available on the host
  • An Intel iGPU if you want machine learning acceleration
  • For a migration: access to the old system, and enough space for the library on the new one

Your values

Fill these in and every command below updates to match. Nothing is sent anywhere.

Run this at your own risk. These steps worked on my hardware; yours may differ. Take a backup first, read each command before running it, and see the disclaimer.

Immich is four containers, not one, and they want completely different things from your storage. Get that split right at the start and everything afterwards is easier - including the migration, which is the part most people get wrong.

The short version of the hard-won lesson: your photos are not the valuable part. Every face you have named, every album, every person and every place is in the Postgres database. Copy the photos to a new server and let Immich re-import them, and you keep the pictures and lose everything you did with them.

What the four parts are, and what each one needs

Container What it does Where it belongs
immich_server Web interface, uploads, the API Library on bulk storage
immich_postgres Faces, albums, people, search vectors Fast storage. This is the crown jewels.
immich_machine_learning Face detection, smart search, OCR Model cache on fast storage
immich_redis Job queue and cache Nothing to persist

The database does far more random IO than a media server's does. Every smart search and every face match hits a vector index, so putting it on spinning disks makes the whole interface feel slow. Redis is a cache and deliberately has no volume at all - if it is lost, jobs re-queue.

Plan the storage

bash
zfs create -p nvme/databases/immich
zfs create -p nvme/apps/immich/model-cache
zfs create -p tank/Immich

Three datasets, three jobs:

  • {{NVME_POOL_PATH}}/databases/immich - the Postgres data directory
  • {{NVME_POOL_PATH}}/apps/immich/model-cache - the downloaded ML models, a few GB, read constantly during scanning
  • {{HDD_POOL_PATH}}/Immich - the photo library, thumbnails and encoded video

Separating the database onto its own dataset matters beyond speed: you can snapshot it on its own schedule, and a database snapshot is small and quick while a library snapshot is neither.

Configure it

Immich is configured by a .env file next to the compose file. The paths are the important part.

bash
mkdir -p /mnt/nvme/apps/immich
cd /mnt/nvme/apps/immich
cat > .env <<'EOF'
# Where the photo library lives - bulk storage
UPLOAD_LOCATION=/mnt/tank/Immich

# Where the Postgres data directory lives - fast storage
DB_DATA_LOCATION=/mnt/nvme/databases/immich

# Pin the version. Never run :release in production and wonder why it changed.
IMMICH_VERSION=v3.1.0

TZ=Etc/UTC

DB_PASSWORD=CHANGEME
DB_USERNAME=postgres
DB_DATABASE_NAME=immich
EOF

Change DB_PASSWORD before you start anything. It is only used between the containers, but it ends up written into the database on first run, and changing it afterwards is more annoying than choosing it now.

Enable GPU acceleration properly

Machine learning on the CPU works, and it is slow. On a library of any size, face detection and smart search indexing take days rather than hours. An Intel iGPU with OpenVINO changes that.

Two things are needed and both must be true. This is where people lose time, because getting one of them right looks exactly like getting both right until you check.

First, the machine learning image must be the OpenVINO variant - note the suffix:

expected output
ghcr.io/immich-app/immich-machine-learning:v3.1.0-openvino

Second, the container must actually be able to reach the GPU. Immich ships a hwaccel.ml.yml file for this; the openvino section maps the render device and sets the cgroup rule that permits it:

bash
cd /mnt/nvme/apps/immich
grep -A 8 'openvino:' hwaccel.ml.yml
expected output
openvino:
  device_cgroup_rules:
    - 'c 189:* rmw'
  devices:
    - /dev/dri:/dev/dri
  volumes:
    - /dev/bus/usb:/dev/bus/usb

In your docker-compose.yml, the machine learning service extends that section:

expected output
immich-machine-learning:
  extends:
    file: hwaccel.ml.yml
    service: openvino

Verify the GPU is actually reachable

Do not skip this. A missing device does not produce an error - it produces a silent fallback to the CPU, and everything keeps working, just slowly, forever.

bash
docker exec immich_machine_learning ls -l /dev/dri
expected output
crw-rw---- 1 root video 226,   0 card0
crw-rw---- 1 root   107 226, 128 renderD128

If that says "No such file or directory", the GPU is not passed through and no amount of configuration elsewhere will help.

Verify OpenVINO is actually being used

The device being present is necessary but not sufficient. Check the logs:

bash
docker logs immich_machine_learning 2>&1 | grep -i -E 'openvino|provider' | tail -5
expected output
INFO  Setting execution providers to ['OpenVINOExecutionProvider',
      'CPUExecutionProvider'], in descending order of priority

OpenVINOExecutionProvider listed first is what you want. CPUExecutionProvider appearing after it is the fallback, which is correct - it should be there. If CPU is the only entry, OpenVINO failed to initialise.

Migrating an existing library

Here is the part worth reading twice.

What lives where. The image files are on disk. Everything else - the people you named, the albums you built, the face clusters, the smart search embeddings, the shared links, the users - is in Postgres. Move the photos alone and Immich will happily re-import them and rebuild face detection from scratch, with none of your names attached.

So the database is the migration. The photos are just bytes that come along.

Route one: restore a database dump. This is the safer path and the one to choose if the two systems differ in any way.

Immich takes its own dump every night. Find it on the old system:

bash
ls -la /mnt/tank/Immich/backups/
expected output
immich-db-backup-20260913T020000-v3.1.0-pg14.19.sql.gz

The filename records the Immich version and the Postgres version, which is exactly what you need to know before restoring it somewhere else.

Copy the photo library and that dump to the new machine, bring up the new stack so it creates an empty database, then stop the server and machine learning containers, leaving Postgres running, and restore into it. Immich's own documentation carries the current restore command for your version - use theirs rather than a copied one, because it changes between releases.

Route two: copy the Postgres data directory. Faster, no restore time, and strictly conditional.

This only works if the Postgres major version and the vector extension versions are identical on both sides. Immich does not use stock Postgres; it uses a build with VectorChord and pgvecto.rs compiled in. A mismatch does not degrade gracefully - the database refuses to start.

If you take this route, pin the image by digest rather than by tag so the two ends cannot drift:

expected output
image: ghcr.io/immich-app/postgres:14-vectorchord0.4.3-pgvectors0.2.0@sha256:...

Stop Immich completely on both ends before copying. A Postgres data directory copied while running is not a database, it is a corrupted one.

bash
Destructive command. Read it before running it. This can delete data or interrupt service.
docker compose down
rsync -aHAX --info=progress2 \
  old-nas.example.lan:/path/to/old/postgres/data/ \
  /mnt/nvme/databases/immich/

Either route: keep the library path consistent. Immich stores paths. If UPLOAD_LOCATION pointed somewhere else on the old system, the database will reference files that are not where it expects them.

External libraries: photos that stay where they are

Immich can index a folder in place rather than importing it. Your existing photo folders keep their structure, keep being written to by whatever put them there, and still appear in Immich with faces and search.

Mount the folder into the server container:

expected output
volumes:
  - /mnt/tank/Immich:/data
  - /mnt/tank/Home/Photos:/external

Then in the web interface, Administration, External Libraries, add /external - the path inside the container, not the host path.

Two things to understand about external libraries. Immich will not move, rename or delete those files, which is the point. And if a file disappears from the folder, Immich marks the asset offline rather than deleting your metadata, so a temporarily unmounted disk does not destroy your albums.

Immich backs up its own database. Check where.

Immich runs a database dump on a schedule and writes it into the library folder:

bash
ls -la /mnt/tank/Immich/backups/ | tail -5

This is genuinely useful and many people do not know it exists.

It also has an obvious flaw if you leave it alone. Those dumps sit on the same pool as the photos they describe. Lose the pool and you lose the photos and the only thing that could rebuild their metadata, in the same event.

Snapshots are not backups. A daily snapshot of the library dataset does capture those dumps, and it protects you from deleting something or from a bad upgrade. It does not protect you from losing the pool, because it lives on the pool. What you want is the snapshots replicated to a second machine:

bash
zfs list -o name | grep -i immich

Run that on the backup machine. If the Immich datasets appear there, the photos and the dumps are both off the box.

If you replicate both the library and the live database directory, know the difference between them. A replicated Postgres data directory is crash-consistent as of the snapshot - usually restorable, but a database captured mid-write is not guaranteed clean. The nightly SQL dump is a proper consistent export.

In a real recovery, reach for the dump first and treat the replicated data directory as the fast option to try second. Having both is the right answer; assuming they are equivalent is not.

After it is running

Check these in order:

  1. docker exec immich_machine_learning ls -l /dev/dri - device present
  2. The logs show OpenVINOExecutionProvider first
  3. Trigger a smart search job and watch the iGPU clock climb: cat /sys/class/drm/card0/gt_act_freq_mhz
  4. Search for a person by name. If they are there, your migration kept the database, and you have kept everything that mattered.