Cloud

Install Immich on Ubuntu 26.04 LTS

Immich has become the self-hosted answer to Google Photos for people tired of quota increases and ad-funded photo scanning. It syncs from your phone automatically, runs ML models for face recognition and CLIP search on your own hardware, and lets you pull a full iPhone or Android roll into a library that never leaves your server. This guide stands up a complete Immich install on Ubuntu 26.04 LTS with Docker, a VectorChord-powered Postgres database, Valkey for caching, an Nginx reverse proxy, and real TLS from Let’s Encrypt. It targets the current Immich 3.0 release, which added mobile non-destructive editing, a drag-and-drop Workflows automation builder, real-time video transcoding, and on-device OCR.

Original content from computingforgeeks.com - post 166943

The second half of the article is a captured deployment story from the test VM: what actually happened when we pulled the images, which container took how long to go healthy, how long the ML models took to download, and the storage footprint after each phase. That kind of captured detail is the part most Immich guides gloss over and the part that trips real setups.

Re-tested July 2026 on Ubuntu 26.04 LTS with Immich 3.0.3, Docker CE 29.6.2, and the bundled Valkey 9 and VectorChord Postgres, behind an Nginx TLS reverse proxy.

Prerequisites

  • Ubuntu 26.04 LTS server, 4 vCPU and 8 GB RAM. The machine-learning worker loads its models on demand and peaks around 1.7 GB during Smart Search and face detection, then unloads when idle. 4 GB hosts work only if you keep those ML jobs off, and you lose most of the draw.
  • A CPU at the x86-64-v2 microarchitecture level or newer. Immich 3.0 bumped its ML dependencies (numpy) to require it, so very old processors and VMs pinned to an emulated kvm64 CPU fail the ML jobs. On Proxmox, set the VM CPU type to host.
  • 60 GB or larger root disk for the OS, Docker images (~6 GB of Immich images), and a starter library. Photos themselves can live on a mounted volume.
  • Domain or subdomain with an A record pointing at the server. Port 80 reachable for Let’s Encrypt HTTP-01.
  • A sudo user; do not ship with root SSH. Run through the post-install baseline checklist first.

Step 1: Set reusable shell variables

Every command in this guide references shell variables so you paste the rest as-is once this block is correct. Edit the values for your domain and choose a strong database password using letters and digits only, which Immich’s own .env requires, then export:

export APP_DOMAIN="immich.example.com"
export IMMICH_ROOT="/opt/immich"
export UPLOAD_LOCATION="/srv/immich/library"
export DB_DATA_LOCATION="/srv/immich/postgres"
export DB_PASS="ChangeMeStr0ngDbPass2026"
export TZ="UTC"
export ADMIN_EMAIL="[email protected]"

Confirm the values are set before running anything destructive:

echo "Domain:  ${APP_DOMAIN}"
echo "Root:    ${IMMICH_ROOT}"
echo "Uploads: ${UPLOAD_LOCATION}"
echo "TZ:      ${TZ}"

The exports only hold for the current shell. If you reconnect or drop into sudo -i, run the block again.

Step 2: Install Docker Engine and Compose

Ubuntu 26.04 ships a docker.io package but the Immich project tests against Docker CE from the official Docker repository. Install that version directly:

sudo apt-get update
sudo DEBIAN_FRONTEND=noninteractive apt-get install -y ca-certificates curl gnupg
sudo install -m 0755 -d /etc/apt/keyrings
sudo curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc
sudo chmod a+r /etc/apt/keyrings/docker.asc
echo 'deb [arch=amd64 signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/ubuntu noble stable' \
    | sudo tee /etc/apt/sources.list.d/docker.list
sudo apt-get update
sudo DEBIAN_FRONTEND=noninteractive apt-get install -y \
    docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin

Note the repo codename is noble, not resolute. Ubuntu 26.04 reports its codename as resolute, and Docker’s mirror does serve a resolute index file, but that suite still ships no packages. The noble build is the one to use, and it runs cleanly on the 26.04 kernel and cgroup v2. Recheck for a populated resolute suite later with apt-cache policy docker-ce.

Verify the install and confirm your user can run Docker without sudo once added to the docker group:

docker --version
docker compose version
sudo usermod -aG docker $USER
newgrp docker
docker run --rm hello-world

Expected output on the test box:

Docker version 29.6.2, build dfc4efb
Docker Compose version v5.3.1

If you want a deeper tour of Docker on this release, see the dedicated Docker install guide.

Step 3: Download the official Immich Compose bundle

Immich publishes a signed Compose bundle on GitHub Releases. Pull the files into ${IMMICH_ROOT}:

sudo mkdir -p "${IMMICH_ROOT}"
cd "${IMMICH_ROOT}"
sudo wget -q https://github.com/immich-app/immich/releases/latest/download/docker-compose.yml
sudo wget -q https://github.com/immich-app/immich/releases/latest/download/example.env -O .env
sudo wget -q https://github.com/immich-app/immich/releases/latest/download/hwaccel.transcoding.yml
sudo wget -q https://github.com/immich-app/immich/releases/latest/download/hwaccel.ml.yml
ls -la

Four files now live in the directory: the Compose manifest, the environment file, and two hardware-acceleration overrides you can opt into later for GPU transcoding and ML inference.

Step 4: Edit the environment file

The defaults in .env put the photo library and Postgres data inside the project directory. For a real install you want them on a larger disk or a mounted volume. Point them at paths under /srv/immich/:

sudo mkdir -p "${UPLOAD_LOCATION}" "${DB_DATA_LOCATION}"
sudo sed -i "s|UPLOAD_LOCATION=./library|UPLOAD_LOCATION=${UPLOAD_LOCATION}|" "${IMMICH_ROOT}/.env"
sudo sed -i "s|DB_DATA_LOCATION=./postgres|DB_DATA_LOCATION=${DB_DATA_LOCATION}|" "${IMMICH_ROOT}/.env"
sudo sed -i "s|^# TZ=Etc/UTC|TZ=${TZ}|" "${IMMICH_ROOT}/.env"
sudo sed -i "s|^DB_PASSWORD=postgres|DB_PASSWORD=${DB_PASS}|" "${IMMICH_ROOT}/.env"

Verify the file now has your values and nothing else was clobbered:

sudo grep -E 'UPLOAD|DB_DATA|TZ=|DB_PASS|IMMICH_VERSION' "${IMMICH_ROOT}/.env"

Leave IMMICH_VERSION=v3 pinned to the major that ships in the current .env. Immich publishes release notes with breaking changes between majors, so staying on a major lets you pull patch updates without surprises. The jump from the 2.x line to 3.0 reworked several API endpoints and dropped the old pgvecto.rs database extension, which is exactly why pinning to a major matters.

Step 5: Pull the images and bring the stack up

Immich is four containers: the API server, a machine-learning worker, Postgres (a vendored build with the VectorChord vector extension; 3.0 removed the legacy pgvecto.rs support, and VectorChord is its successor), and Valkey. The Valkey container is the Redis-compatible cache; the service is still named redis in the Compose file even though the image is now valkey/valkey. Pulling is the slowest single step in the install: the images total about 6 GB.

cd "${IMMICH_ROOT}"
sudo docker compose pull

On the test VM (4 vCPU, 8 GB RAM, 1 Gbps link), the pull finished in about two minutes. Start the stack:

sudo docker compose up -d

Immich’s first start triggers database migrations and schema creation. Give it a full minute before checking health:

sudo docker compose ps

Every service should report healthy. If immich-server is still health: starting after two minutes, check its logs:

sudo docker compose logs immich-server --tail 100

Sanity-check the API is responding on the loopback before setting up the reverse proxy:

curl -s http://localhost:2283/api/server/ping
curl -s http://localhost:2283/api/server/version

Expected responses:

{"res":"pong"}
{"major":3,"minor":0,"patch":3,"prerelease":null}

Loopback works, which means every container is talking to every other container. Next, expose the service to real users.

Step 6: Put Nginx in front with Let’s Encrypt

Immich serves over plain HTTP on port 2283. You want TLS plus a hostname readers can type, which means Nginx as a reverse proxy with a real certificate. Install the web server and certbot:

sudo DEBIAN_FRONTEND=noninteractive apt-get install -y \
    nginx certbot python3-certbot-nginx ufw

Create the reverse-proxy vhost. Start with an HTTP-only server block. Certbot rewrites it to add TLS once the certificate exists, so you avoid the chicken-and-egg of pointing Nginx at certificate files that are not there yet. The placeholder IMMICH_DOMAIN_HERE is deliberate because Nginx does not expand ${APP_DOMAIN} inside config files; a sed step below substitutes it. The body-size and timeout numbers matter, because Immich uploads 4K video and large originals and the default Nginx client_max_body_size of 1 MB rejects them with a 413. Open the file:

sudo vim /etc/nginx/sites-available/immich.conf

Add the HTTP-only vhost:

server {
    listen 80;
    listen [::]:80;
    server_name IMMICH_DOMAIN_HERE;

    client_max_body_size 50000M;
    client_body_timeout 3600s;
    proxy_read_timeout 600s;
    proxy_send_timeout 600s;
    send_timeout 600s;

    add_header Strict-Transport-Security "max-age=15552000; includeSubDomains" always;
    add_header X-Content-Type-Options "nosniff" always;
    add_header X-Frame-Options "SAMEORIGIN" always;

    location / {
        proxy_pass http://127.0.0.1:2283;
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection "upgrade";
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_redirect off;
        proxy_buffering off;
        proxy_request_buffering off;
    }
}

Swap the placeholder for your domain, disable the default site, enable this one, then test and reload:

sudo sed -i "s/IMMICH_DOMAIN_HERE/${APP_DOMAIN}/g" /etc/nginx/sites-available/immich.conf
sudo rm -f /etc/nginx/sites-enabled/default
sudo ln -sf /etc/nginx/sites-available/immich.conf /etc/nginx/sites-enabled/immich.conf
sudo nginx -t && sudo systemctl reload nginx

The config test passes now because the vhost points at nothing that does not exist yet. Open the firewall for HTTP (certbot needs port 80) and HTTPS:

sudo ufw allow OpenSSH
sudo ufw allow 'Nginx Full'
sudo ufw --force enable

Issue the certificate. The --nginx plugin answers the HTTP-01 challenge, then injects the listen 443 ssl block, the certificate paths, and the HTTP-to-HTTPS redirect into your vhost for you:

sudo certbot --nginx -d "${APP_DOMAIN}" \
    --non-interactive --agree-tos --redirect \
    -m "${ADMIN_EMAIL}"

A successful run prints a 90-day expiry and wires up the renewal timer. Confirm it is active:

sudo systemctl list-timers certbot.timer
sudo certbot renew --dry-run

A green “simulated renewal succeeded” line proves the 90-day renewal cycle is wired up. For a deeper Nginx + Let’s Encrypt walkthrough, see the dedicated guide.

Alternative: DNS-01 challenge for private or NAT’d hosts

If your server lives behind NAT and port 80 cannot reach the internet, use a DNS-01 plugin instead. Certbot ships providers for Cloudflare, Route 53, DigitalOcean, Google Cloud DNS, Linode, OVH, and RFC2136. The Cloudflare example below is from our private Proxmox test box. Install the plugin:

sudo apt-get install -y python3-certbot-dns-cloudflare

Store the scoped API token in a credentials file and lock it to root:

echo "dns_cloudflare_api_token = your-cloudflare-api-token" | sudo tee /etc/letsencrypt/cloudflare.ini
sudo chmod 600 /etc/letsencrypt/cloudflare.ini

Obtain the certificate over DNS, which needs no inbound port 80:

sudo certbot certonly --dns-cloudflare \
    --dns-cloudflare-credentials /etc/letsencrypt/cloudflare.ini \
    --dns-cloudflare-propagation-seconds 30 \
    -d "${APP_DOMAIN}" \
    --non-interactive --agree-tos -m "${ADMIN_EMAIL}"

certbot certonly only fetches the certificate; it does not touch Nginx. Install it into the same HTTP-only vhost with the nginx installer, which adds the TLS block and redirect exactly as the HTTP-01 path did:

sudo certbot install --nginx --cert-name "${APP_DOMAIN}"
sudo nginx -t && sudo systemctl reload nginx

Either path ends with the same result: a valid certificate and an Nginx vhost serving Immich over HTTPS with the upload limits and proxy headers intact.

Step 7: Create the first admin

Open https://${APP_DOMAIN}/. On a fresh 3.0 install the first visit shows a Welcome screen. Click Getting Started to reach the admin-registration form. The Restore From Backup option beside it is new in 3.0 and seeds a server from an existing backup instead of starting empty. The registration form appears because no accounts exist yet:

Immich 3.0 first admin registration screen on Ubuntu 26.04

Fill in the admin email, a strong password, and a display name, then submit. You are redirected to the login page. Log in and Immich runs a short onboarding wizard covering theme, timezone, and a few server defaults, one step per card. Click through; every choice is editable later from Administration.

Once onboarding finishes you land on the Photos timeline. A fresh install is empty with a big upload-your-first-photo card, and the running version shows bottom-left next to the green Server Online indicator (v3.0.3 on the test box):

Immich 3.0 Photos timeline showing server version v3.0.3 on Ubuntu 26.04

The sidebar on the left holds every section you will spend time in: Photos, Explore, Map, Sharing, and under Library, Favorites, Albums, Utilities, Archive, Locked Folder, and Trash. The search bar carries a Context toggle for natural-language photo search. Storage usage sits bottom-left so you can watch the library grow.

Step 8: Install the mobile app and connect

The reason most people run Immich is the auto-upload mobile app. Install the Immich app from the iOS App Store or Google Play, then connect:

  1. Server URL: https://${APP_DOMAIN}
  2. Log in with the admin email and password you just created
  3. Backup screen: toggle “Backup” on, pick which albums to back up, enable foreground plus background uploads

The first backup is bandwidth-heavy. On iOS, leave the phone plugged in and connected to Wi-Fi for the first upload of a multi-year photo roll. Expect 10 to 20 GB per thousand photos depending on camera quality and how many Live Photos you shoot.

Step 9: Create additional users and manage jobs

Immich is multi-tenant. Add family accounts or team members from Administration. Every user gets their own library, their own shared albums, and can belong to shared albums you own.

Immich 3.0 admin user management page on Ubuntu 26.04

Administration, Job Queues (renamed from Jobs in 3.0) is where you watch the ML pipeline work. After the first batch upload, expect these queues to fill with active jobs: Generate Thumbnails, Extract Metadata, Smart Search (CLIP embeddings), Face Detection, Face Recognition, Sidecar, and Storage Template Migration.

Immich 3.0 admin job queues page on Ubuntu 26.04

Concurrency is tunable per queue from Manage Concurrency. The default is one worker per queue, which is conservative. On a 4-core box bumping Smart Search and Face Detection to 2 each keeps CPU busy without thrashing the database.

Step 10: Verify the stack end to end

Terminal verification confirms what the UI already shows. The screenshot below is a live run on the test VM:

Immich 3.0 docker compose ps showing Valkey and VectorChord Postgres on Ubuntu 26.04

All four containers report healthy, the image tags confirm the 3.0 server and machine-learning worker alongside the Valkey cache and the VectorChord Postgres build, and the API returns major version 3. If any container is stuck in health: starting, inspect its logs before moving on:

sudo docker compose logs immich-server immich-machine-learning --tail 50

For broader host hardening around the Immich box (SSH keys, fail2ban, kernel hardening), pair this guide with the server hardening guide and lock down the UFW firewall to only ports 22 and 443 once testing is done.

Troubleshooting

Upload failed: 413 Request Entity Too Large

Nginx rejected an upload. The vhost in Step 6 already raises the limit to 50000M but a dropped edit or a differently-configured proxy will still 413 on anything above 1 MB. Confirm:

grep client_max_body_size /etc/nginx/sites-enabled/immich.conf
sudo nginx -t && sudo systemctl reload nginx

If the value reads 50000M and Nginx still 413s, something else in the proxy chain is capping the body. Cloudflare’s free tier, for example, caps uploads at 100 MB regardless of your Nginx config.

ML container OOM on 4 GB host

The default Immich ML config loads a CLIP model plus a face-detection model. On a 4 GB VM the worker gets killed by the OOM reaper during the first big batch. Two fixes:

  1. Turn off Smart Search or Facial Recognition from Administration, Job Queues, until you can add RAM
  2. Edit hwaccel.ml.yml, enable CPU offload, restart the stack

8 GB is the practical floor for the full feature set on a single host.

Database or ML container fails on an emulated CPU

Immich ships a vendored Postgres build (VectorChord in 3.0), and the 3.0 machine-learning worker bumped numpy to require the x86-64-v2 microarchitecture level. On a VM pinned to an emulated kvm64 CPU you hit one of two failures: the database crashes on start with SIGILL or illegal instruction, or the Smart Search and face-detection jobs fail. Both trace back to the CPU missing modern instructions that these images assume. On Proxmox, set the VM CPU type to host so the guest sees the real instruction set:

qm set <vmid> --cpu host

A reboot, then docker compose up -d again, and the database starts cleanly.

WebSocket drops during long uploads

The WebSocket keepalive times out when proxy_read_timeout is too short. The default of 60 s is nowhere near enough for a multi-gigabyte upload. The vhost in Step 6 uses 600 s, which handles 4K videos on typical home uplinks. If you still see drops, raise to 1800 s.

First Smart Search or face job is slow

In 3.0 the machine-learning worker starts healthy in seconds and loads its models on demand rather than downloading everything at boot. The first Smart Search or face-detection job is the slow one: the worker fetches the CLIP and face-detection models before it processes anything, and its memory climbs from about 250 MB idle to roughly 1.7 GB while a model is resident. Watch the progress:

sudo docker compose logs -f immich-machine-learning

After the first run the weights are cached, the worker unloads them after five minutes of inactivity, and later jobs start fast.

Real-world deployment: what actually happened on the test VM

Specs: Ubuntu 26.04 LTS cloud image, 4 vCPU, 8 GB RAM, 60 GB virtio disk on a Proxmox host with the CPU type set to host, 1 Gbps LAN uplink. Immich 3.0.3, one admin user, no HWACCEL overlays. The pull time, startup, and idle-resource numbers below were re-measured on the 3.0 stack. The per-1000-photo disk and job-timing tables come from the original seeded library and reflect workload behavior that carries across the 2.x to 3.0 update, since the CLIP and face models and the ffmpeg transcode path are unchanged.

Image pull and first start

The four Immich images together weigh about 6 GB. On this VM the pull finished in roughly two minutes (123 seconds on this run). Breakdown from docker images after pull:

ImageSize
ghcr.io/immich-app/immich-server:v33.11 GB
ghcr.io/immich-app/immich-machine-learning:v31.85 GB
ghcr.io/immich-app/postgres:14-vectorchord0.4.3-pgvectors0.2.0989 MB
valkey/valkey:9183 MB

First docker compose up -d to all four containers reporting healthy: about 30 seconds (the server flipped to healthy at 24 s). Postgres came up first (migrations complete in a few seconds on a fresh database), then Valkey, then the ML worker, then the server last once it could reach the database. That is quicker than the 2.x line because the ML worker no longer downloads its models before it reports healthy.

Empty-state resource usage

With zero photos uploaded, measured five minutes after up -d settled:

ContainerCPURSS
immich_server0.09%1.56 GB
immich_machine_learning0.13%248 MB (models unloaded when idle)
immich_postgres0.03%451 MB
immich_redis (Valkey)0.17%14 MB

Idle footprint on the test VM was about 2.8 GB of RAM. The machine-learning worker sits low at idle (around 250 MB) because 3.0 unloads models after five minutes of inactivity, but it climbs to roughly 1.7 GB while Smart Search or face detection runs. Budget for that spike: 8 GB stays the recommended floor for the full feature set on a single host.

Disk growth per 1000 photos

We seeded the library with a 1000-photo mix of JPEG (average 4 MB), HEIC (2.5 MB), and a handful of 30-second 4K MP4 clips (average 90 MB). Before upload, /srv/immich/library was 0 bytes; after upload and after all background jobs finished, it held:

SubdirectorySizeNotes
upload/3.8 GBOriginal files, Immich does not transcode or strip EXIF
thumbs/340 MBLarge + small + blurred thumbnails per asset
encoded-video/520 MBWeb-friendly transcodes of the 4K clips
profile/14 MBUser avatars and shared-album covers

Total: 4.68 GB for 1000 photos. That is roughly 4.8 MB per photo including transcodes and thumbnails; plan storage accordingly. Scaled to a 30,000-photo family library (≈10 years of iPhone backups), expect about 140 GB after full processing.

Background job timing

Times measured with concurrency left at default (1 worker per queue):

Job1000-photo durationNotes
Generate Thumbnails4 m 20 sCPU-bound; bumping to 2 workers cut this to 2 m 40 s
Extract Metadata1 m 10 sNegligible even on slow disks
Smart Search (CLIP)9 m 50 sThe heaviest job; every image goes through the CLIP model
Face Detection6 m 30 sSkips images with no detected faces
Face Recognition2 m 15 sRuns only on assets with detected faces
Video Conversion18 m 12 sThree 4K clips; ffmpeg burn is the bottleneck

Aggregate pipeline from upload to every job idle: about 22 minutes for the 1000-photo batch. A GPU passthrough (covered in hwaccel.ml.yml) cuts the CLIP and face-detection times by roughly 8x on a modest Nvidia card, which matters only when seeding multi-year libraries.

The surprise

The 2.x line downloaded its ML models at container start and blocked the healthcheck for the first few minutes, which reliably made first-time installers panic and restart the stack. 3.0 flips that. The ML worker reports healthy in seconds and loads models lazily on the first job that needs them. The upshot is a much lower idle memory number than older guides quote (around 250 MB, not 1.2 GB), but the first Smart Search or face-detection run is where the model download and the memory spike land instead. Watch the first job, not the boot.

The worker then unloads models after five minutes of inactivity, so a mostly-idle server drops back to a few hundred MB and only pays for the ML footprint while it is actively indexing photos.

Keep reading

Upgrade Ubuntu 24.04 to Ubuntu 26.04 LTS (Step by Step) Ubuntu Upgrade Ubuntu 24.04 to Ubuntu 26.04 LTS (Step by Step) UFW Firewall Commands with Examples on Ubuntu 24.04 / 22.04 Security UFW Firewall Commands with Examples on Ubuntu 24.04 / 22.04 Backup and Restore Linux Systems with Timeshift Debian Backup and Restore Linux Systems with Timeshift Best HashiCorp Terraform Associate Books for the 004 Exam Books Best HashiCorp Terraform Associate Books for the 004 Exam Best AWS Certified Solutions Architect Associate Books for SAA-C03 Books Best AWS Certified Solutions Architect Associate Books for SAA-C03 Install and Configure OpenCart e-commerce platform on Ubuntu 20.04 Ubuntu Install and Configure OpenCart e-commerce platform on Ubuntu 20.04

Leave a Comment

Press ESC to close