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.
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
kvm64CPU fail the ML jobs. On Proxmox, set the VM CPU type tohost. - 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:

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):

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:
- Server URL:
https://${APP_DOMAIN} - Log in with the admin email and password you just created
- 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.

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.

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:

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:
- Turn off Smart Search or Facial Recognition from Administration, Job Queues, until you can add RAM
- 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:
| Image | Size |
|---|---|
ghcr.io/immich-app/immich-server:v3 | 3.11 GB |
ghcr.io/immich-app/immich-machine-learning:v3 | 1.85 GB |
ghcr.io/immich-app/postgres:14-vectorchord0.4.3-pgvectors0.2.0 | 989 MB |
valkey/valkey:9 | 183 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:
| Container | CPU | RSS |
|---|---|---|
immich_server | 0.09% | 1.56 GB |
immich_machine_learning | 0.13% | 248 MB (models unloaded when idle) |
immich_postgres | 0.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:
| Subdirectory | Size | Notes |
|---|---|---|
upload/ | 3.8 GB | Original files, Immich does not transcode or strip EXIF |
thumbs/ | 340 MB | Large + small + blurred thumbnails per asset |
encoded-video/ | 520 MB | Web-friendly transcodes of the 4K clips |
profile/ | 14 MB | User 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):
| Job | 1000-photo duration | Notes |
|---|---|---|
| Generate Thumbnails | 4 m 20 s | CPU-bound; bumping to 2 workers cut this to 2 m 40 s |
| Extract Metadata | 1 m 10 s | Negligible even on slow disks |
| Smart Search (CLIP) | 9 m 50 s | The heaviest job; every image goes through the CLIP model |
| Face Detection | 6 m 30 s | Skips images with no detected faces |
| Face Recognition | 2 m 15 s | Runs only on assets with detected faces |
| Video Conversion | 18 m 12 s | Three 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.