Deploy Immich on IncusOS
Here are the steps I followed to set up Immich on my home server using IncusOS.
I chose to do it via Incus’ native OCI container support, instead of going through the more usual approach of spinning up a Docker host in an instance. As far as I can tell this approach hasn’t been documented anywhere yet, which makes me quite happy to be able to contribute it.
What differs in this approach
Going with Incus native OCI support means we cannot rely on docker-compose: although incus-compose is quite promising, it is not there yet for this use case. We will have to do everything manually, and that’s what this post covers.
Since it wouldn’t be fun if this was my sole “handicap”, I’m also using the immutable OS purpose-built for Incus. This means we cannot use bind mounts for the persisted data: instead we will use custom filesystem storage volumes, which on IncusOS are backed by ZFS file system datasets.
Step 1: Download and review the upstream config
The first step is to read and understand what the upstream Docker Compose setup would do.
Follow the official instructions to retrieve the files and pay attention to where and how the variables from the .env file are used in the Compose file.
Here’s the upstream .env as of writing this:
# You can find documentation for all the supported env variables at https://docs.immich.app/install/environment-variables
# The location where your uploaded files are stored
UPLOAD_LOCATION=./library
# The location where your database files are stored. Network shares are not supported for the database
DB_DATA_LOCATION=./postgres
# To set a timezone, uncomment the next line and change Etc/UTC to a TZ identifier from this list: https://en.wikipedia.org/wiki/List_of_tz_database_time_zones#List
# TZ=Etc/UTC
# The Immich version to use. You can pin this to a specific version like "v2.1.0"
IMMICH_VERSION=v2
# Connection secret for postgres. You should change it to a random password
# Please use only the characters `A-Za-z0-9`, without special characters or spaces
DB_PASSWORD=postgres
# The values below this line do not need to be changed
###################################################################################
DB_USERNAME=postgres
DB_DATABASE_NAME=immich
As outlined in Immich docs, UPLOAD_LOCATION, DB_DATA_LOCATION and IMMICH_VERSION are only useful if using Docker Compose, so I commented them out.
I also uncommented and set the TZ variable and changed the DB_PASSWORD.
Since we are not using Docker Compose and its network shenanigans helpers, we also need to set the database and Redis HOST environment variables so that our Immich server is able to find them.
IMMICH_MACHINE_LEARNING_URL was deprecated in 2024 but still works, otherwise it can be configured in the web app settings or via the config file.
Result for me:
# You can find documentation for all the supported env variables at https://docs.immich.app/install/environment-variables
# The location where your uploaded files are stored
# not used with Incus
#UPLOAD_LOCATION=library
# The location where your database files are stored. Network shares are not supported for the database
# not used with Incus
#DB_DATA_LOCATION=postgres
# To set a timezone, uncomment the next line and change Etc/UTC to a TZ identifier from this list: https://en.wikipedia.org/wiki/List_of_tz_database_time_zones#List
TZ=Europe/Paris
# The Immich version to use. You can pin this to a specific version like "v2.1.0"
# not used with Incus
#IMMICH_VERSION=v2
# Connection secret for postgres. You should change it to a random password
# Please use only the characters `A-Za-z0-9`, without special characters or spaces
DB_PASSWORD=MySuperStrongPassword
# The values below this line do not need to be changed
###################################################################################
DB_USERNAME=postgres
DB_DATABASE_NAME=immich
# Needed when not using Docker Compose
# Uncomment the DB_STORAGE_TYPE: 'HDD' var if your database isn't stored on SSDs
#DB_STORAGE_TYPE=HDD
DB_HOSTNAME=immich-postgres.incus
REDIS_HOSTNAME=immich-redis.incus
IMMICH_MACHINE_LEARNING_URL=http://immich-ml.incus:3003
Note the Incus-specific .incus TLD used: this is the default DNS domain used on managed bridges, and allows to reach instances on the same network with their hostname (which here is automatically derived from the instance name).
With the application config out of the way, let’s move to setting up the infrastructure.
Step 2: Set up a dedicated Incus project and network
This is optional, but to separate concerns I prefer to group multi-container Compose projects inside dedicated Incus projects. This way they get their own namespace which can be managed independently from other projects:
incus project create immich --config features.images=false
--config features.images=falsedisables images isolation, i.e. images in this project are shared with the default project.
Incus instances needs a default profile to define their root disk and default network, so we copy the default profile from the default project into our immich project:
incus profile show default | incus profile edit default --project immich
I also set up a dedicated network to better replicate what docker-compose does by default and be able to manage more easily it later on.
For reasons unknown to me, Incus managed bridged networks cannot be created outside the default project, so we have to create it there first and then the immich project use it for by default:
incus network create immich-net
incus profile device set default eth0 network=immich-net --project immich
# optionnal: restrict the project to using only this network
incus project set immich restricted.networks.access=immich-net
Before we continue, let’s switch the CLI context to this project so as to avoid having to add --project immich to every single command:
incus project switch immich
Step 3: Create the storage volumes
This will be where all our important project data will be stored. The upstream compose file uses two bind mounts and one named volume, which we replace with three custom storage volumes:
for i in postgres uploads model-cache; do
incus storage volume create local immich_$i
done
default instead of local.
Then it’s finally time to create the containers themselves!
Step 4: Create the containers
We’ll proceed in reverse order compared to how the Compose file is laid out, with the database, redis and machine-learning containers first and immich-server last.
Here’s the database container section of the official Compose file:
database:
container_name: immich_postgres
image: ghcr.io/immich-app/postgres:14-vectorchord0.4.3-pgvectors0.2.0@sha256:bcf63357191b76a916ae5eb93464d65c07511da41e3bf7a8416db519b40b1c23
environment:
POSTGRES_PASSWORD: ${DB_PASSWORD}
POSTGRES_USER: ${DB_USERNAME}
POSTGRES_DB: ${DB_DATABASE_NAME}
POSTGRES_INITDB_ARGS: '--data-checksums'
# Uncomment the DB_STORAGE_TYPE: 'HDD' var if your database isn't stored on SSDs
# DB_STORAGE_TYPE: 'HDD'
volumes:
# Do not edit the next line. If you want to change the database storage location on your system, edit the value of DB_DATA_LOCATION in the .env file
- ${DB_DATA_LOCATION}:/var/lib/postgresql/data
shm_size: 128mb
restart: always
healthcheck:
disable: false
First we create the container itself with the required environment variables:
incus create \
ghcr:immich-app/postgres:14-vectorchord0.4.3-pgvectors0.2.0@sha256:bcf63357191b76a916ae5eb93464d65c07511da41e3bf7a8416db519b40b1c23 \
immich-postgres \
-c environment.POSTGRES_PASSWORD="MySuperStrongPassword" \
-c environment.POSTGRES_USER="postgres" \
-c environment.POSTGRES_DB="immich" \
-c environment.POSTGRES_INITDB_ARGS="--data-checksums"
Or, if like me you’d rather keep the environment in a separate file:
cat > .postgres-env <<EOF
POSTGRES_PASSWORD=MySuperStrongPassword
POSTGRES_USER=postgres
POSTGRES_DB=immich
POSTGRES_INITDB_ARGS=--data-checksums
EOF
incus create \
ghcr:immich-app/postgres:14-vectorchord0.4.3-pgvectors0.2.0@sha256:bcf63357191b76a916ae5eb93464d65c07511da41e3bf7a8416db519b40b1c23 \
immich-postgres \
--environment-file .postgres-env
Warning
Do not use quotes for the values inside the env file, as it leads to double-quoting and the container will fail to start: see my bug report (update: this was fixed while I was writing this post and is awaiting a release).Then we attach the database volume to the created container:
incus config device add immich-postgres pgdata disk \
pool=local \
source=immich_postgres \
path=/var/lib/postgresql/data
# or
# incus storage volume attach local immich_postgres immich-postgres pgdata /var/lib/postgresql/data
As well as a tmpfs one to equate the shm_size: 128mb from the Compose file:
incus config device add immich-postgres shm disk \
source=tmpfs: \
size=128MiB \
path=/dev/shm
Now we can launch our container and check its console output to make sure it started correctly:
incus start immich-postgres
incus console immich-postgres --show-log
I won’t repeat this last part for the next ones, but it is a good idea to check the console logs every time you start a new container (you can add --console to incus launch|start as well).
The Redis/Valkey one is the simplest, going from:
redis:
container_name: immich_redis
image: docker.io/valkey/valkey:9@sha256:3b55fbaa0cd93cf0d9d961f405e4dfcc70efe325e2d84da207a0a8e6d8fde4f9
healthcheck:
test: redis-cli ping || exit 1
restart: always
To:
incus launch \
docker:valkey/valkey:9@sha256:3b55fbaa0cd93cf0d9d961f405e4dfcc70efe325e2d84da207a0a8e6d8fde4f9 \
immich-redis
I set up the machine learning one without hardware acceleration, as IncusOS doesn’t yet provide what is required at the host level, so any GPU acceleration which requires installing something on the host is only available inside VMs with a GPU passthrough.
Without it the container setup is pretty straightforward:
# Create the container with its environment populated with the .env file content
incus create \
ghcr:immich-app/immich-machine-learning:v2 \
immich-ml \
--environment-file .env
# Attach the model-cache volume
incus config device add immich-ml model-cache disk \
pool=local \
source=immich-model-cache \
path=/cache
incus start immich-ml
It’s finally time for the pièce de résistance, immich-server itself. This is where we need the extra environment variables for connecting to the other containers, so if you have already added them to the .env file it is as simple as:
incus create \
ghcr:immich-app/immich-server:v2 \
immich-server \
--environment-file .env
Otherwise you need to add the following to the previous command:
-c environment.DB_HOSTNAME=immich-postgres.incus \
-c environment.REDIS_HOSTNAME=immich-redis.incus \
-c environment.IMMICH_MACHINE_LEARNING_URL=http://immich-ml.incus:3003
For storage we have the all so important library/uploads volume:
incus config device add immich-server uploads disk \
pool=local \
source=immich-uploads \
path=/data
To replicate the exposed port form the Compose file we can use a proxy device:
incus config device add immich-server web proxy \
listen="tcp:0.0.0.0:2283" \
connect="tcp:127.0.0.1:2283"
This will make sure to redirect connections on the host port 2283 to the container’s.
Now we are finally ready to start it:
incus start immich-server
Check the logs to make sure everything is ok!
Tip
If you have set a different timezone in IncusOS (or on your regular OS where Incus is installed), you might want to replicate the bind mount of /etc/localtime; however liblxc prevents mounting over a symlink, and so starting the container with the host localtime mounted over the container’s one fails.
Two possible workarounds: start the container without mounting /etc/localtime, and either:
-
delete the file inside the container and then mount it from the host via a disk device:
# make sure the container is started incus start immich-server # delete the existing symlink incus exec immich-server -- rm /etc/localtime # mount the file from the incus config device add immich-server localtime disk \ source=$(realpath /etc/localtime) \ path=/etc/localtime \ readonly=true -
overwrite the symlink inside the container with the desired timezone file:
incus exec immich-server -- ln -sf /usr/share/zoneinfo/Europe/Paris /etc/localtime
Both workarounds will need to be reapplied anytime you rebuild/update the container.
Accessing Immich
Thanks to the proxy device, it is just a matter of opening http://<server-ip>:2283 in a browser (https doesn’t work) and completing the Post-Install setup.
I was able to set up the mobile app on my phone and upload pictures right away, and it’s been running fine since then. Pretty nice!
Improvement: set a start/stop order
After restarting the server a few times, I noticed that because I didn’t set a boot.autostart.priority the containers were being started in parallel, which resulted in errors in immich-server logs showing it was waiting on the postgres one.
Therefore I configured a start and stop priority for immich-server:
incus config set immich-server boot.autostart.priority=0 boot.stop.priority=100
This benefits from the fact that “Instances without a priority set will be started (with some parallelism) ahead of instances with a priority set”, which matches exactly what we want in this case: the three supporting containers should be started before immich-server proper.
We could also set boot.autorestart to true so that immich-server gets restarted up to 10 times over a minute in case of unexpected failure.
Conclusion
I hope this will help some of you set up Immich “the hard way” on Incus/IncusOS! Let me know by email or on the forums if you have any feedback.