Skip to content

Self-host Immich with Nginx Proxy Manager

A beginner-friendly, end-to-end guide to deploy Immich behind Nginx Proxy Manager on a single Linux host with an NVIDIA GPU. One compose file, one folder. Every piece of state lives in bind-mounted directories — no Docker named volumes. The GPU is used for:

  • Machine Learning — Smart Search (CLIP), Facial Recognition, Duplicate Detection
  • Hardware Transcoding — NVENC

When you're done you'll have:

  • https://immich.yourdomain.tld serving Immich with a free Let's Encrypt certificate
  • Everything in /opt/docker/immich/
  • Containers pinned to exact versions — no surprise updates
  • A reverse-proxy network that creates itself when the stack comes up
  • A proper backup-and-restore script ready to schedule

Video walkthrough — coming soon

This guide pairs with an upcoming Distro Domain YouTube video. Once it's published, the embed will appear right here. In the meantime, the written guide stands on its own.


Prerequisites — host setup

This guide is written against Ubuntu (the distro used in the paired video). The compose stack itself runs on any modern Linux; only the install commands below are Ubuntu-specific. On Debian/Fedora/etc. the conceptual steps are identical — see upstream docs for the equivalent packages.

What you need

  • An Ubuntu host (Ubuntu Server in the video)
  • A domain name pointing to the host's public IP (or LAN IP for internal-only use)
  • Ports 80 and 443 reachable from wherever you'll access Immich
  • Port 81 reachable from your LAN (NPM's admin UI — never expose to the internet)
  • A user with sudo / root
  • An NVIDIA GPU (optional — see the no-GPU note below)

Step 1 — Install the NVIDIA driver

sudo apt install nvidia-driver-580-server

Pick a driver version that's actually compatible with your card

For older GPUs like the GTX 1080 Ti, driver 580 is more reliable than the newer 595 — I tried 595 on a 1080 Ti and it wouldn't load. If you're on a newer card, you can try a newer driver first (e.g. nvidia-driver-595-server); fall back to 580 if it doesn't load.

Reboot so the new driver loads into a fresh kernel:

sudo reboot

After reboot, verify the driver is loaded:

nvidia-smi

You should see your GPU model and the driver version.

No GPU? You can still deploy

Skip Steps 1, 3, and the GPU smoke test below. In the compose file, change the ML image tag from :v2.7.5-cuda to :v2.7.5 and remove both deploy: blocks. Everything still works on CPU. Smart Search of a large library will take longer.

Step 2 — Install Docker

Add Docker's official Apt repo, then install Docker Engine + the Compose plugin.

# Prereqs
sudo apt update
sudo apt install -y ca-certificates curl

# Add Docker's GPG key
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

# Add the repository to Apt sources
sudo tee /etc/apt/sources.list.d/docker.sources <<EOF
Types: deb
URIs: https://download.docker.com/linux/ubuntu
Suites: $(. /etc/os-release && echo "${UBUNTU_CODENAME:-$VERSION_CODENAME}")
Components: stable
Architectures: $(dpkg --print-architecture)
Signed-By: /etc/apt/keyrings/docker.asc
EOF

sudo apt update

Install Docker Engine, the CLI, containerd, buildx, and the Compose plugin:

sudo apt install -y \
    docker-ce docker-ce-cli containerd.io \
    docker-buildx-plugin docker-compose-plugin

Verify Docker is running:

sudo systemctl status docker

Should show active (running).

About docker compose vs docker-compose

The Compose plugin (installed above as docker-compose-plugin) is the modern way — commands are spelled docker compose ... (two words). Legacy docker-compose (hyphenated, Python-based) is deprecated.

Step 3 — Install the NVIDIA Container Toolkit

This is what lets Docker containers see and use the host's GPU.

Add NVIDIA's apt repo:

curl -fsSL https://nvidia.github.io/libnvidia-container/gpgkey \
  | sudo gpg --dearmor -o /usr/share/keyrings/nvidia-container-toolkit-keyring.gpg

curl -s -L https://nvidia.github.io/libnvidia-container/stable/deb/nvidia-container-toolkit.list \
  | sed 's#deb https://#deb [signed-by=/usr/share/keyrings/nvidia-container-toolkit-keyring.gpg] https://#g' \
  | sudo tee /etc/apt/sources.list.d/nvidia-container-toolkit.list

sudo apt update

Install the toolkit packages:

sudo apt-get install -y \
    nvidia-container-toolkit \
    nvidia-container-toolkit-base \
    libnvidia-container-tools \
    libnvidia-container1

Wire the toolkit into Docker and restart the daemon so it picks up the change:

sudo nvidia-ctk runtime configure --runtime=docker
sudo systemctl restart docker

Step 4 — Smoke test the GPU stack

This MUST work before continuing:

docker run --rm --gpus all nvidia/cuda:12.4.0-base-ubuntu22.04 nvidia-smi

If it prints your GPU, the GPU stack is ready. If you get could not select driver 'nvidia', the nvidia-ctk runtime configure step didn't take — re-run it and restart Docker.


Folder layout

All stack state lives in one folder: /opt/docker/immich/. The backup script lives separately at /root/immich-backup.sh (so it survives a rm -rf of the stack directory during a restore drill — see Test the restore below).

/opt/docker/immich/
├── docker-compose.yml      ← the entire stack (NPM + Immich)
├── .env                    ← secrets + timezone (loaded by compose)
├── npm/
│   ├── data/               ← NPM's SQLite DB, certs metadata, custom configs
│   └── letsencrypt/        ← Let's Encrypt account + issued certificates
├── library/                ← photos, video, thumbs, encoded video, profile pics, backups
├── postgres/               ← PostgreSQL data directory
└── model-cache/            ← CLIP / face-detection model files (downloaded lazily)

/root/
├── immich-backup.sh        ← backup/restore script (added later)
└── immich-backups/         ← backup archives land here

Create it:

sudo mkdir -p /opt/docker/immich/{npm/data,npm/letsencrypt,library,postgres,model-cache}
cd /opt/docker/immich

Why one folder?

Backups are dead simple — rsync or tar the whole directory. Migration to a new host is "stop the stack, copy the folder, start it." No hunting for Docker volumes elsewhere on the disk.


The compose file

Save this as /opt/docker/immich/docker-compose.yml. Two versions below — the Clean tab is what you actually copy-paste; the Annotated tab explains what every line does. Read the annotated version once if you're new to Compose; after that the clean one is what you maintain.

View the compose file
name: immich

services:

  nginx-proxy:
    image: 'jc21/nginx-proxy-manager:2.12.6'
    container_name: nginx-proxy
    ports:
      - '80:80'
      - '443:443'
      - '81:81'
    volumes:
      - ./npm/data:/data
      - ./npm/letsencrypt:/etc/letsencrypt
    extra_hosts:
      - 'host.docker.internal:host-gateway'
    networks:
      proxy:
        ipv4_address: 172.19.0.2
    restart: unless-stopped

  immich-server:
    image: 'ghcr.io/immich-app/immich-server:v2.7.5'
    container_name: immich_server
    env_file:
      - .env
    volumes:
      - ./library:/data
      - /etc/localtime:/etc/localtime:ro
    depends_on:
      - redis
      - database
    healthcheck:
      disable: false
    restart: unless-stopped
    deploy:
      resources:
        reservations:
          devices:
            - driver: nvidia
              count: 1
              capabilities: [gpu, compute, video]
    networks:
      proxy:
        ipv4_address: 172.19.0.10
      immich_internal:

  immich-machine-learning:
    image: 'ghcr.io/immich-app/immich-machine-learning:v2.7.5-cuda'
    container_name: immich_machine_learning
    env_file:
      - .env
    volumes:
      - ./model-cache:/cache
    healthcheck:
      disable: false
    restart: unless-stopped
    deploy:
      resources:
        reservations:
          devices:
            - driver: nvidia
              count: 1
              capabilities: [gpu]
    networks:
      - immich_internal

  redis:
    image: 'docker.io/valkey/valkey:9.0.4'
    container_name: immich_redis
    healthcheck:
      test: redis-cli ping || exit 1
    restart: unless-stopped
    networks:
      - immich_internal

  database:
    image: 'ghcr.io/immich-app/postgres:14-vectorchord0.4.3-pgvectors0.2.0'
    container_name: immich_postgres
    environment:
      POSTGRES_PASSWORD: '${DB_PASSWORD}'
      POSTGRES_USER: '${DB_USERNAME}'
      POSTGRES_DB: '${DB_DATABASE_NAME}'
      POSTGRES_INITDB_ARGS: '--data-checksums'
    volumes:
      - ./postgres:/var/lib/postgresql/data
    shm_size: 128mb
    healthcheck:
      disable: false
    restart: unless-stopped
    networks:
      - immich_internal

networks:
  proxy:
    driver: bridge
    ipam:
      config:
        - subnet: 172.19.0.0/24
          gateway: 172.19.0.1

  immich_internal:
    driver: bridge
# A "compose project" is the unit Docker Compose manages. The `name:` becomes
# the prefix for resources it creates (networks, volumes). Here it's `immich`.
name: immich

# `services:` is the list of containers in this project. Each indented block
# below this line defines one container.
services:

  # ───────────────────────────────────────────────────────────────────────
  # SERVICE 1 — Reverse proxy (Nginx Proxy Manager)
  # The public front door. Terminates HTTPS, gets Let's Encrypt certs,
  # forwards traffic to immich-server. Has a friendly web UI.
  # ───────────────────────────────────────────────────────────────────────
  nginx-proxy:
    # The exact image+tag to run. Pinned — never use `:latest` for prod.
    image: 'jc21/nginx-proxy-manager:2.12.6'
    # What `docker ps` will show this container as.
    container_name: nginx-proxy
    # Host-port:container-port. LEFT side is your machine; RIGHT side
    # is inside the container. These three ports are exposed to the host.
    ports:
      - '80:80'      # HTTP — needed for Let's Encrypt HTTP-01 challenges
      - '443:443'    # HTTPS — your users hit this
      - '81:81'      # Admin UI — keep this LAN-only with a host firewall!
    # Bind mounts: `./host-path:/container-path`. The `./` is relative to
    # the folder containing this compose file. Anything written to the
    # right-hand path inside the container appears in the left-hand path
    # on the host.
    volumes:
      - ./npm/data:/data
      - ./npm/letsencrypt:/etc/letsencrypt
    # Lets the container resolve `host.docker.internal` to the host's IP —
    # useful if you ever forward NPM to a service running on the host.
    extra_hosts:
      - 'host.docker.internal:host-gateway'
    # Attach this container to networks. We give it a fixed IP on `proxy`
    # so the proxy host config in NPM can point to a stable address.
    networks:
      proxy:
        ipv4_address: 172.19.0.2
    # If the container exits (crash, host reboot, etc.) bring it back up,
    # unless we explicitly `docker compose stop`'d it.
    restart: unless-stopped

  # ───────────────────────────────────────────────────────────────────────
  # SERVICE 2 — Immich web/API server
  # The main Immich application. Web UI, REST API, mobile-app endpoint,
  # background job runner. Talks to Postgres + Redis + the ML service.
  # ───────────────────────────────────────────────────────────────────────
  immich-server:
    image: 'ghcr.io/immich-app/immich-server:v2.7.5'
    container_name: immich_server
    # Read environment variables from this file (TZ, DB creds, etc.)
    env_file:
      - .env
    volumes:
      # All photos / videos / thumbnails / encoded video / profile pics
      # land in ./library on the host.
      - ./library:/data
      # Sync container time with host so timestamps match.
      - /etc/localtime:/etc/localtime:ro
    # Don't start until redis + database are running. This is "started",
    # NOT "healthy" — the app handles brief unavailability with retries.
    depends_on:
      - redis
      - database
    # Use the healthcheck baked into the image. `docker compose ps` will
    # show `(healthy)` when it's actually ready to serve requests.
    healthcheck:
      disable: false
    restart: unless-stopped
    # ── GPU access for hardware video transcoding (NVENC) ──
    # `compute` + `video` capabilities are what ffmpeg needs for NVENC.
    # If you don't have an NVIDIA GPU, delete this whole `deploy:` block.
    deploy:
      resources:
        reservations:
          devices:
            - driver: nvidia
              count: 1
              capabilities: [gpu, compute, video]
    # ── Two networks ──
    # `proxy` (with a fixed IP) so NPM can reach it.
    # `immich_internal` (private bridge) so it can reach redis/postgres/ML.
    networks:
      proxy:
        ipv4_address: 172.19.0.10
      immich_internal:

  # ───────────────────────────────────────────────────────────────────────
  # SERVICE 3 — Machine learning service
  # Runs CLIP (smart search), face detection, face recognition, duplicate
  # detection. Separate container so it can use a CUDA-flavored image and
  # GPU resources independent of the main server. Only reachable from
  # immich-server over the internal network.
  # ───────────────────────────────────────────────────────────────────────
  immich-machine-learning:
    # The `-cuda` suffix is what makes ML run on GPU. For CPU-only use
    # the plain `:v2.7.5` tag and delete the `deploy:` block below.
    image: 'ghcr.io/immich-app/immich-machine-learning:v2.7.5-cuda'
    container_name: immich_machine_learning
    env_file:
      - .env
    volumes:
      # Downloaded ML models are cached here. Survives container removal.
      - ./model-cache:/cache
    healthcheck:
      disable: false
    restart: unless-stopped
    # ── GPU access for CUDA inference ──
    deploy:
      resources:
        reservations:
          devices:
            - driver: nvidia
              count: 1
              capabilities: [gpu]
    # Internal network ONLY — nothing outside the stack can reach it.
    networks:
      - immich_internal

  # ───────────────────────────────────────────────────────────────────────
  # SERVICE 4 — Redis-compatible cache (Valkey is the open-source fork)
  # Used by Immich for the job queue and short-lived caching.
  # Stateless — no volumes needed.
  # ───────────────────────────────────────────────────────────────────────
  redis:
    image: 'docker.io/valkey/valkey:9.0.4'
    container_name: immich_redis
    healthcheck:
      # If `redis-cli ping` doesn't return PONG, container is unhealthy.
      test: redis-cli ping || exit 1
    restart: unless-stopped
    networks:
      - immich_internal

  # ───────────────────────────────────────────────────────────────────────
  # SERVICE 5 — PostgreSQL with vector extensions
  # The Immich-flavored Postgres ships with `vectorchord` + `pgvectors`,
  # which Immich uses to store image embeddings for similarity search.
  # ───────────────────────────────────────────────────────────────────────
  database:
    image: 'ghcr.io/immich-app/postgres:14-vectorchord0.4.3-pgvectors0.2.0'
    container_name: immich_postgres
    # ${...} values come from the .env file in this folder.
    environment:
      POSTGRES_PASSWORD: '${DB_PASSWORD}'
      POSTGRES_USER: '${DB_USERNAME}'
      POSTGRES_DB: '${DB_DATABASE_NAME}'
      # Enables block-level checksums — detects silent data corruption.
      POSTGRES_INITDB_ARGS: '--data-checksums'
    volumes:
      - ./postgres:/var/lib/postgresql/data
    # /dev/shm size for Postgres — the default 64 MB can cause issues
    # with parallel queries. 128 MB is the Immich-recommended value.
    shm_size: 128mb
    healthcheck:
      disable: false
    restart: unless-stopped
    networks:
      - immich_internal

# ───────────────────────────────────────────────────────────────────────────
# NETWORKS
# Defining them here (instead of using `external: true`) means Compose
# will CREATE these networks the first time you run `docker compose up`,
# and remove them when you run `docker compose down`. No manual setup.
# ───────────────────────────────────────────────────────────────────────────
networks:
  # The "public-facing" bridge. NPM and immich-server live here. Anything
  # else you add later that should be reachable through NPM can join this
  # network too. Fixed subnet so the static IPs above are predictable.
  proxy:
    driver: bridge
    ipam:
      config:
        - subnet: 172.19.0.0/24
          gateway: 172.19.0.1

  # Private back-end bridge. Redis, Postgres, ML, and immich-server live
  # here. Nothing outside the stack can reach these services.
  immich_internal:
    driver: bridge

The .env file

Save as /opt/docker/immich/.env:

# Timezone — pick from https://en.wikipedia.org/wiki/List_of_tz_database_time_zones
TZ=Etc/UTC

# Database password — generate a fresh one with:
#   openssl rand -hex 24
# Use ONLY A-Za-z0-9. Immich does not handle special characters here.
DB_PASSWORD=replace-me-with-a-random-string

# Don't change these unless you know why.
DB_USERNAME=postgres
DB_DATABASE_NAME=immich

Generate the password:

openssl rand -hex 24

Paste the output into DB_PASSWORD.

Set the password BEFORE the first docker compose up

Once Postgres initializes its data directory, changing this is a multi-step rotation — not impossible, but annoying.


Compose concepts cheat-sheet

A quick glossary for anyone new to Compose:

Term What it means
service A container definition. Each service spawns one container of that image.
image The OCI image to run, like name:tag. Pinning the tag (not :latest) is how you control updates.
container_name The friendly name shown in docker ps. Without it, Compose auto-generates one.
ports Maps a host port to a container port. Only the services that need to talk to the outside world get this.
volumes Persistent storage. ./host:/container is a bind mount — the host path is the truth.
env_file Reads KEY=VALUE lines and injects them into the container's environment.
environment Inline env vars. Can reference ${KEY} from env_file or your shell.
depends_on Boot ordering. Doesn't wait for healthy, just for the dependency to exist.
healthcheck Periodic command the runtime runs to decide if a container is OK.
restart What to do on exit. unless-stopped = always restart unless you explicitly stopped it.
deploy.resources.reservations.devices How you request GPU access from the NVIDIA Container Toolkit.
networks Which Docker networks the container joins. You can join multiple.

And two top-level keys:

Term What it means
services: All your containers.
networks: Docker networks. Declaring them here makes Compose create them automatically.

Start the stack

cd /opt/docker/immich
docker compose pull          # downloads ~7 GB the first time
docker compose up -d         # creates networks, starts all 5 containers
docker compose ps            # check status

Watch them all become healthy:

watch -n 2 'docker compose ps'

Press Ctrl+C when all five show (healthy).

Verify the GPU is actually in use

# Should print your GPU
docker exec immich_machine_learning nvidia-smi -L

# Should print HTTP 200 — NPM can reach immich by container name
docker exec nginx-proxy curl -fsS -o /dev/null -w 'HTTP %{http_code}\n' \
  http://immich_server:2283/api/server/ping

First-time NPM setup

Browse to http://<host-ip>:81. Default credentials (you'll be forced to change them on first login):

Email:    [email protected]
Password: changeme

Never expose port 81 to the internet

Block it at your host firewall (ufw deny 81, firewalld, etc.) — it's LAN-only.

Add the Immich proxy host

Hosts → Proxy Hosts → Add Proxy Host

Details tab:

Field Value
Domain Names immich.yourdomain.tld
Scheme http
Forward Hostname / IP immich_server
Forward Port 2283
Cache Assets off
Block Common Exploits on
Websockets Support on (required)

Why a container name instead of an IP

Docker's user-defined networks ship with an embedded DNS server at 127.0.0.11. Any container on the same network can resolve another container's name (or service name) straight to its current IP. NPM and Immich are both on the proxy network, so from NPM's perspective immich_server resolves to the right container automatically. This is more robust than hard-coding 172.19.0.10 — if you ever re-create the container, Docker hands NPM the new IP without a config change. It's also easier to read at a glance.

SSL tab:

Two ways to get a Let's Encrypt certificate. Pick whichever fits your DNS setup.

The DNS-01 challenge proves you own the domain by adding a temporary TXT record on it. NPM can do this automatically if your DNS provider supports API access (Cloudflare, Route 53, DigitalOcean, Hetzner, Linode, and ~40 others are supported out of the box). The big advantage: you don't need port 80 exposed to the internet at all — your only inbound surface is 443. Smaller attack surface, simpler firewall rules, and you also get the ability to issue wildcard certs (*.distrodomain.com).

In NPM's SSL tab:

  • SSL Certificate: Request a new SSL Certificate
  • Use a DNS Challenge: ✅ on
  • DNS Provider: Cloudflare (or whichever you use)
  • Credentials File Content: paste your provider's API token in the format NPM expects — for Cloudflare it's:
    dns_cloudflare_api_token = your-api-token-here
    
  • Email Address for Let's Encrypt: your real email (for renewal-failure notifications)
  • Force SSL: ✅ · HTTP/2 Support: ✅ · HSTS Enabled: ✅
  • Accept the Let's Encrypt TOS

For Cloudflare specifically: generate the API token at My Profile → API Tokens → Create Token, use the "Edit zone DNS" template, and scope it to just the zone you're certifying.

Use this if your DNS provider doesn't have API access (or you don't want to set up a token). The HTTP-01 challenge requires Let's Encrypt to reach NPM on port 80 from the public internet — LE makes a GET request to http://yourdomain.tld/.well-known/acme-challenge/..., NPM serves the response, and LE issues the cert.

Trade-offs:

  • Port 80 must be forwarded from your router to the NPM host, and stay open for renewals every ~60 days.
  • Slightly larger attack surface than DNS-01 (port 80 is exposed even though it just redirects to 443 in normal traffic).
  • No wildcard certs — each subdomain needs its own.

In NPM's SSL tab:

  • SSL Certificate: Request a new SSL Certificate
  • Use a DNS Challenge: ❌ off (default)
  • Email Address for Let's Encrypt: your real email
  • Force SSL: ✅ · HTTP/2 Support: ✅ · HSTS Enabled: ✅
  • Accept the Let's Encrypt TOS

Before saving: verify port 80 is reachable from the internet (curl -I http://yourdomain.tld/ from a phone on cellular data is a good test). NPM will fail the cert request if the HTTP-01 challenge can't reach it.

Advanced tab — required for Immich (without this, uploads break at ~1 MB):

client_max_body_size 50000M;
proxy_read_timeout   600s;
proxy_send_timeout   600s;
proxy_request_buffering off;

Save. Open https://immich.yourdomain.tld and create the admin account.


Enable ML + NVENC in the Immich admin UI

Click your avatar (top right) → Administration → Settings.

Machine Learning Settings (usually already on)

  • Enable Machine Learning
  • URL: http://immich-machine-learning:3003 (the default — works via the immich_internal bridge)
  • Smart Search
  • Facial Recognition
  • Duplicate Detection

Default models work well. Bigger CLIP models (e.g. ViT-H-14-378-quickgelu__dfn5b) give better search results but use more VRAM — try them once everything else is working.

Video Transcoding Settings (NVENC)

  • Acceleration API: NVENC
  • Constant Quality: 23
  • Preset: p1 (fastest) — p4 for higher quality
  • Two-pass encoding: off

Save.

Run the initial jobs

Administration → Jobs, in order:

  1. Smart Search → All
  2. Face Detection → All
  3. Facial Recognition → All
  4. Duplicate Detection → All
  5. (Optional) Transcode Video → All — re-encode existing videos with NVENC

Watch the GPU in real time as the jobs run. nvtop is the right tool for this — a live top-style view of GPU utilization, memory, and per-process load:

sudo apt install nvtop
nvtop

You'll see the immich_ml_main Python process during ML jobs and ffmpeg during transcoding load up your VRAM and SMs. That's confirmation the GPU is actually doing the work, not the CPU.


Backup & restore

The Immich stack stores state in five places: Postgres (the database), Valkey (stateless — nothing to back up), the ML model cache (rebuildable from scratch), the photo library, and NPM's data + Let's Encrypt certs. A proper backup captures the SQL dump (while the DB is up, transactionally consistent) plus a snapshot of the on-disk state (while the stack is briefly stopped, for filesystem consistency).

The immich-backup.sh script below handles all of it end-to-end:

  • Two modesfull (everything including the photo library) or stack-only (everything except the library, useful when the library already lives on an NFS/SMB share that's backed up separately).
  • Transactionally consistent DB dump taken while the stack is up.
  • Filesystem-consistent snapshot of the bind mounts, taken with the stack briefly stopped (~30 seconds).
  • Manifest describing what's in the archive — date, hostname, image tags pinned at the time of backup.
  • SHA-256 checksum sidecar.
  • Ships to local path, remote SSH, or rsync daemon with one flag.
  • Symmetric restore — same script, restore subcommand. Restores either from the raw Postgres datadir (fast, same PG version) or from the SQL dump (slower, but works across PG major versions).

Install it

The video puts the script at /root/immich-backup.sh — keeping it separate from the stack directory means a destructive restore drill (which wipes /opt/docker/immich/) doesn't take the script with it.

# Save the script as /root/immich-backup.sh (expand the block below, copy-paste)
sudo chmod +x /root/immich-backup.sh

# Preflight — these binaries must be on PATH:
for c in docker tar gzip sha256sum awk df du; do
  command -v "$c" >/dev/null || echo "missing: $c"
done

Usage — taking a backup

The exact command from the video — full backup to a local directory under /root/:

sudo /root/immich-backup.sh backup \
    --mode full \
    --dest /root/immich-backups

What the script does, in order:

  1. Dumps the live Postgres database (transactionally consistent, while the stack is up)
  2. Shows a plan summary and asks Proceed? Stack will be stopped briefly. [y/N]
  3. Stops the stack (typically 30–60 seconds of downtime)
  4. Tars + gzips the stack directory (and the library, in full mode)
  5. Restarts the stack
  6. Writes a SHA-256 sidecar next to the archive
  7. Moves the archive to --dest

Other common patterns:

# Stack-only to a remote host (use this when the library lives on a NAS share
# that's already backed up separately)
sudo /root/immich-backup.sh backup --mode stack-only \
    --dest [email protected]:/srv/backups/immich

# Dry run — print the plan without touching anything
sudo /root/immich-backup.sh backup --mode full --dest /tmp --dry-run

# Full help
/root/immich-backup.sh --help

Test the restore

A backup you've never restored from is a hope, not a backup.

The video demos a full destructive restore drill: stop the stack, delete /opt/docker/immich/ entirely, and rebuild it from the archive. This is the only way to know your backup actually works end-to-end.

# 1. Find the most recent archive
ls -t /root/immich-backups/

# 2. Take the stack down and wipe the data directory
cd /opt/docker/immich
docker compose down
sudo rm -rf /opt/docker/immich
sudo mkdir -p /opt/docker/immich

# 3. Restore from the archive (the exact form shown in the video)
sudo /root/immich-backup.sh restore \
    --archive /root/immich-backups/immich-FULL-2026-05-19_0041.tar.gz

# 4. Watch the stack come back healthy
watch -n 1 'docker compose -f /opt/docker/immich/docker-compose.yml ps'

The restore script will:

  1. Verify the archive's SHA-256 checksum (refuses to proceed if it's corrupt)
  2. Print the manifest from the backup — what was captured, when, and which image versions were pinned
  3. Ask Proceed? [y/N]
  4. Extract everything back into /opt/docker/immich/
  5. Bring the stack up

Once the stack is healthy, log into NPM and Immich and verify your photos, certificates, and proxy configs are all there. If they are, the backup is real.

Don't run the wipe-and-restore drill on production data

The drill above is destructive — rm -rf /opt/docker/immich is the literal command. Practice on a test host first, or take a fresh backup right before so you can recover even if the restore fails. Once you've verified the round-trip works once, you can trust scheduled backups to be real.

Restore on a new host with a different Postgres version

If the destination host runs a different Postgres major version (e.g., upgrading from PG14 to PG16), use --from-sql to rebuild the database from the SQL dump in the archive instead of copying the raw datadir:

sudo /root/immich-backup.sh restore --from-sql \
    --archive /root/immich-backups/immich-FULL-2026-05-19_0041.tar.gz

Schedule it

For nightly backups, drop the script in cron:

sudo crontab -e
# Every night at 02:30 — full backup to /root/immich-backups/
30 2 * * *  /root/immich-backup.sh backup --mode full --yes --dest /root/immich-backups >> /var/log/immich-backup.log 2>&1

The --yes flag skips the interactive confirmation. Logs go to /var/log/immich-backup.log.

The script itself

View the full backup/restore script (581 lines)
#!/usr/bin/env bash
# =============================================================================
#  immich-backup.sh — backup & restore for an Immich docker-compose stack
# =============================================================================
#
#  Designed for the stack deployed per the Distro Domain Immich guide.
#  NPM + Immich, all bind mounts, postgres + valkey + ML + server + nginx-proxy
#  in a single compose.
#
#  TWO BACKUP MODES
#  ----------------
#    full         Everything: configs + DB + model cache + the photo library.
#                 Use when the library lives on the same disk as the stack.
#
#    stack-only   Everything EXCEPT the photo library. Use when the library
#                 already lives on a SMB/NFS share that is backed up separately.
#
#  HOW IT WORKS
#  ------------
#    1. Dumps the live Postgres database to SQL (pg_dumpall).
#    2. Stops the stack so the on-disk files are in a consistent state.
#    3. Tars + gzips:
#         manifest.txt              — what's in this archive, when, image tags
#         postgres-dump.sql.gz      — restore-anywhere DB dump
#         immich/                   — everything in --immich-dir except library
#         library/                  — only in 'full' mode
#    4. Writes a .sha256 sidecar.
#    5. Copies the archive to the destination (local cp or rsync over SSH).
#    6. Restarts the stack.
#
#  RESTORE
#  -------
#    The restore subcommand reverses the process: extract the tarball into
#    the right places and start the stack. By default it uses the raw
#    postgres datadir from the archive (fast). Pass --from-sql to rebuild
#    the DB from the embedded pg_dumpall (use this if the destination
#    Postgres version is different from where the backup was taken).
# =============================================================================

# Bail on any uncaught error, undefined variable, or failure in a pipe.
set -Eeuo pipefail

# -----------------------------------------------------------------------------
#  Colour helpers — purely cosmetic. Disable when stdout isn't a TTY so logs
#  don't get cluttered with escape codes when piped to a file.
# -----------------------------------------------------------------------------
if [[ -t 1 ]]; then
  C_RESET=$'\033[0m'; C_BOLD=$'\033[1m'
  C_RED=$'\033[31m'; C_GRN=$'\033[32m'; C_YEL=$'\033[33m'; C_BLU=$'\033[34m'
else
  C_RESET=''; C_BOLD=''; C_RED=''; C_GRN=''; C_YEL=''; C_BLU=''
fi

log()  { printf '%s==>%s %s\n' "${C_BLU}${C_BOLD}" "${C_RESET}" "$*"; }
ok()   { printf '%s OK%s  %s\n' "${C_GRN}${C_BOLD}" "${C_RESET}" "$*"; }
warn() { printf '%sWARN%s %s\n' "${C_YEL}${C_BOLD}" "${C_RESET}" "$*"; }
err()  { printf '%sFAIL%s %s\n' "${C_RED}${C_BOLD}" "${C_RESET}" "$*" >&2; }
die()  { err "$*"; exit 1; }

# -----------------------------------------------------------------------------
#  Defaults — override on the command line.
# -----------------------------------------------------------------------------
IMMICH_DIR_DEFAULT="/opt/docker/immich"
MODE_DEFAULT="full"

# -----------------------------------------------------------------------------
#  Help text. Shown by --help and when no subcommand is given.
# -----------------------------------------------------------------------------
usage() {
  cat <<EOF
${C_BOLD}immich-backup.sh${C_RESET} — backup & restore for an Immich docker-compose stack

${C_BOLD}USAGE${C_RESET}
  $0 backup  [options]
  $0 restore [options]
  $0 --help

${C_BOLD}BACKUP OPTIONS${C_RESET}
  --mode full|stack-only      What to capture (default: ${MODE_DEFAULT})
                                full       = everything incl. the photo library
                                stack-only = everything EXCEPT the photo library
  --immich-dir PATH           Folder containing docker-compose.yml
                                (default: ${IMMICH_DIR_DEFAULT})
  --library-path PATH         Where the photo library lives.
                                If omitted, the script tries to read it from
                                the immich-server :/data volume in compose,
                                and prompts you if it can't figure it out.
  --dest DEST                 Where to put the archive. One of:
                                /local/path
                                user@host:/remote/path
                                rsync://host/module/path
                                (default: current directory)
  --keep-local                Keep a copy of the archive in /tmp after
                                shipping it to --dest (default: delete).
  --yes                       Don't ask "is this OK?" before stopping stack.
  --dry-run                   Print the plan without doing anything.

${C_BOLD}RESTORE OPTIONS${C_RESET}
  --archive FILE              Path to the .tar.gz to restore (required)
  --immich-dir PATH           Where to restore the stack to
                                (default: ${IMMICH_DIR_DEFAULT})
  --library-path PATH         Where to restore the library to.
                                Only used when the archive contains a library/
                                directory (i.e. it was a 'full' backup).
                                Defaults to the same path recorded in the
                                archive's manifest.
  --from-sql                  Rebuild Postgres from the SQL dump in the archive
                                instead of copying the raw datadir. Use this
                                when restoring to a different PG major version.
  --force                     Allow overwriting an existing non-empty target.
  --yes                       Don't ask for final confirmation.

${C_BOLD}EXAMPLES${C_RESET}
  sudo $0 backup --mode full      --dest /mnt/nas/immich-backups
  sudo $0 backup --mode stack-only --dest [email protected]:/srv/backups/immich
  sudo $0 restore --archive /mnt/nas/immich-backups/immich-FULL-2026-05-19_0230.tar.gz
EOF
}

# -----------------------------------------------------------------------------
#  preflight — fail early if the host is missing things we need.
# -----------------------------------------------------------------------------
preflight() {
  local -a need=("$@")
  local missing=()
  for cmd in "${need[@]}"; do
    command -v "$cmd" >/dev/null 2>&1 || missing+=("$cmd")
  done
  if (( ${#missing[@]} > 0 )); then
    die "missing required commands: ${missing[*]}"
  fi
}

# -----------------------------------------------------------------------------
#  compose() — wrapper that always points docker compose at our project, no
#  matter what directory the script was invoked from. The --project-directory
#  flag is what makes relative paths inside compose (./library, ./postgres,
#  ./npm) resolve correctly.
# -----------------------------------------------------------------------------
compose() {
  docker compose -f "${IMMICH_DIR}/docker-compose.yml" \
                 --project-directory "${IMMICH_DIR}" "$@"
}

# -----------------------------------------------------------------------------
#  detect_library_path — read compose, find immich-server's :/data volume,
#  return the host-side path. Falls back to an interactive prompt.
# -----------------------------------------------------------------------------
detect_library_path() {
  local compose_file="${IMMICH_DIR}/docker-compose.yml"
  [[ -r "$compose_file" ]] || die "cannot read $compose_file"

  local raw
  raw="$(compose config 2>/dev/null | awk '
    /^  immich-server:$/ { in_svc=1; next }
    in_svc && /^  [a-zA-Z]/ && !/^  immich-server/ { in_svc=0 }
    in_svc && /source:/   { src=$2 }
    in_svc && /target: \/data$/ { print src; exit }
  ')"

  if [[ -z "$raw" ]]; then
    warn "could not auto-detect library path from $compose_file"
    read -r -p "Enter the photo library path on the host: " raw
  fi

  if [[ "$raw" != /* ]]; then
    raw="${IMMICH_DIR}/${raw#./}"
  fi
  printf '%s' "${raw%/}"
}

free_space_mib() { df -PBM "$1" | awk 'NR==2 {sub(/M/,"",$4); print $4}'; }
dir_size_mib()   { du -sBM "$1" 2>/dev/null | awk '{sub(/M/,"",$1); print $1}'; }

# -----------------------------------------------------------------------------
#  push_archive  LOCAL_FILE  DEST
#  -----------------------------
#  Copy LOCAL_FILE (and its .sha256 sidecar) to DEST. DEST may be:
#    /local/path                  → cp
#    user@host:/remote/path       → rsync over SSH
#    rsync://host/module/path     → rsync to a daemon
# -----------------------------------------------------------------------------
push_archive() {
  local file="$1" dest="$2" sidecar="${1}.sha256"
  case "$dest" in
    rsync://*|*:/*)
      preflight rsync
      log "rsync → $dest"
      rsync -aP --partial --inplace "$file" "$sidecar" "$dest"/
      ;;
    *)
      mkdir -p "$dest"
      log "cp → $dest"
      cp -v "$file" "$sidecar" "$dest"/
      ;;
  esac
}

stack_running() { [[ -n "$(compose ps -q 2>/dev/null)" ]]; }

# -----------------------------------------------------------------------------
#  pg_dump_to  DEST_FILE — streams pg_dumpall while the stack is up so we get
#  a transactionally consistent snapshot from Postgres itself.
# -----------------------------------------------------------------------------
pg_dump_to() {
  local target="$1"
  log "dumping Postgres → $(basename "$target")"
  compose exec -T database pg_dumpall -U postgres \
    | gzip -c > "$target"
  ok "Postgres dump: $(du -h "$target" | cut -f1)"
}

# =============================================================================
#  BACKUP
# =============================================================================
do_backup() {
  local mode="$MODE_DEFAULT"
  IMMICH_DIR="$IMMICH_DIR_DEFAULT"
  local library_path=""
  local dest="$PWD"
  local dry_run=0 keep_local=0 confirm=1

  while (( $# )); do
    case "$1" in
      --mode)         mode="$2"; shift 2;;
      --immich-dir)   IMMICH_DIR="$2"; shift 2;;
      --library-path) library_path="$2"; shift 2;;
      --dest)         dest="$2"; shift 2;;
      --keep-local)   keep_local=1; shift;;
      --yes)          confirm=0; shift;;
      --dry-run)      dry_run=1; shift;;
      -h|--help)      usage; exit 0;;
      *) die "unknown backup option: $1 (try --help)";;
    esac
  done

  [[ "$mode" == "full" || "$mode" == "stack-only" ]] \
    || die "--mode must be 'full' or 'stack-only', got '$mode'"

  preflight docker tar gzip sha256sum awk df du

  [[ -d "$IMMICH_DIR" ]] || die "$IMMICH_DIR does not exist"
  [[ -f "$IMMICH_DIR/docker-compose.yml" ]] \
    || die "no docker-compose.yml in $IMMICH_DIR"

  [[ -n "$library_path" ]] || library_path="$(detect_library_path)"
  if [[ "$mode" == "full" && ! -d "$library_path" ]]; then
    die "library path '$library_path' is not a directory"
  fi

  local stamp; stamp="$(date +%F_%H%M)"
  local tag="STACK"; [[ "$mode" == "full" ]] && tag="FULL"
  local archive_name="immich-${tag}-${stamp}.tar.gz"
  local staging; staging="$(mktemp -d -t immich-backup.XXXXXX)"
  local archive_path="${staging}/${archive_name}"

  # Always run this no matter how we exit — never leave the stack stopped.
  trap "
    code=\$?
    if ! stack_running; then
      log 'restarting stack…'
      compose up -d >/dev/null 2>&1 || warn 'could not restart stack'
    fi
    rm -rf '$staging'
    exit \$code
  " EXIT INT TERM

  cat <<EOF
${C_BOLD}backup plan${C_RESET}
  mode         : $mode
  immich dir   : $IMMICH_DIR
  library path : $library_path $([[ "$mode" == "stack-only" ]] && echo "(excluded)")
  destination  : $dest
  archive name : $archive_name
EOF

  if [[ "$dest" != *:* && "$dest" != rsync://* ]]; then
    mkdir -p "$dest"
    local need=200
    need=$(( need + $(dir_size_mib "$IMMICH_DIR/postgres" 2>/dev/null || echo 0) ))
    [[ "$mode" == "full" ]] \
      && need=$(( need + $(dir_size_mib "$library_path" 2>/dev/null || echo 0) ))
    local free; free="$(free_space_mib "$dest")"
    log "dest free: ${free} MiB ; estimated archive: ~${need} MiB"
    if (( free < need )); then
      warn "destination may not have enough space"
    fi
  fi

  if (( dry_run )); then
    ok "dry-run complete — no changes made"
    trap - EXIT INT TERM; rm -rf "$staging"; exit 0
  fi

  if (( confirm )); then
    read -r -p "Proceed? Stack will be stopped briefly. [y/N] " ans
    [[ "$ans" =~ ^[Yy]$ ]] || die "aborted by user"
  fi

  # 1. SQL dump while the DB is still up
  pg_dump_to "${staging}/postgres-dump.sql.gz"

  # 2. Manifest
  log "writing manifest…"
  {
    echo "backup_date:   $(date -Iseconds)"
    echo "hostname:      $(hostname)"
    echo "mode:          $mode"
    echo "immich_dir:    $IMMICH_DIR"
    echo "library_path:  $library_path"
    echo
    echo "# Image digests (resolved at backup time):"
    compose config --images 2>/dev/null | sort -u | sed 's/^/  /'
    echo
    echo "# Files in this archive:"
    echo "  manifest.txt"
    echo "  postgres-dump.sql.gz"
    echo "  immich/  (everything from \$immich_dir except library)"
    [[ "$mode" == "full" ]] && echo "  library/ (contents of \$library_path)"
  } > "${staging}/manifest.txt"

  # 3. Stop the stack for filesystem consistency
  log "stopping stack…"
  compose stop

  # 4. Tar the bind-mounted state
  log "creating $archive_name…"
  mkdir -p "${staging}/immich"
  ( cd "$IMMICH_DIR" \
      && tar --exclude="./library" -cf - . ) \
      | ( cd "${staging}/immich" && tar -xf - )

  if [[ "$mode" == "full" && "$library_path" != "$IMMICH_DIR/library" ]]; then
    log "copying external library: $library_path"
    mkdir -p "${staging}/library"
    ( cd "$library_path" && tar -cf - . ) | ( cd "${staging}/library" && tar -xf - )
  fi
  if [[ "$mode" == "full" && "$library_path" == "$IMMICH_DIR/library" ]]; then
    log "copying internal library"
    mkdir -p "${staging}/library"
    ( cd "$library_path" && tar -cf - . ) | ( cd "${staging}/library" && tar -xf - )
  fi

  # 5. Restart stack ASAP
  log "restarting stack…"
  compose up -d >/dev/null

  # 6. Roll into one gzipped tarball
  ( cd "$staging" && tar -czf "$archive_path" \
        manifest.txt postgres-dump.sql.gz immich \
        $([[ -d "${staging}/library" ]] && echo "library") )

  ok "archive: $(du -h "$archive_path" | cut -f1)"

  # 7. Checksum sidecar
  log "computing sha256…"
  ( cd "$staging" && sha256sum "$archive_name" > "${archive_name}.sha256" )
  ok "$(cat "${archive_path}.sha256")"

  # 8. Ship it
  push_archive "$archive_path" "$dest"

  if (( ! keep_local )); then
    rm -f "$archive_path" "${archive_path}.sha256"
  else
    cp "$archive_path" "${archive_path}.sha256" /tmp/
    ok "local copies: /tmp/$archive_name (+ .sha256)"
  fi

  ok "backup complete"
  trap - EXIT INT TERM
  rm -rf "$staging"
}

# =============================================================================
#  RESTORE
# =============================================================================
do_restore() {
  IMMICH_DIR="$IMMICH_DIR_DEFAULT"
  local archive=""
  local library_path=""
  local from_sql=0 force=0 confirm=1

  while (( $# )); do
    case "$1" in
      --archive)      archive="$2"; shift 2;;
      --immich-dir)   IMMICH_DIR="$2"; shift 2;;
      --library-path) library_path="$2"; shift 2;;
      --from-sql)     from_sql=1; shift;;
      --force)        force=1; shift;;
      --yes)          confirm=0; shift;;
      -h|--help)      usage; exit 0;;
      *) die "unknown restore option: $1 (try --help)";;
    esac
  done

  [[ -n "$archive" ]] || die "--archive is required"
  [[ -f "$archive" ]] || die "archive not found: $archive"
  preflight docker tar gzip sha256sum

  local staging; staging="$(mktemp -d -t immich-restore.XXXXXX)"
  trap "rm -rf '$staging'" EXIT INT TERM

  if [[ -f "${archive}.sha256" ]]; then
    log "verifying checksum…"
    ( cd "$(dirname "$archive")" && sha256sum -c "$(basename "${archive}.sha256")" ) \
      || die "checksum FAILED — refusing to restore from a corrupt archive"
    ok "checksum verified"
  else
    warn "no .sha256 sidecar next to archive — skipping integrity check"
  fi

  log "extracting archive…"
  tar -xzf "$archive" -C "$staging"
  [[ -f "${staging}/manifest.txt" ]] || die "archive is missing manifest.txt"

  echo "${C_BOLD}--- manifest.txt ---${C_RESET}"
  cat "${staging}/manifest.txt"
  echo "${C_BOLD}--------------------${C_RESET}"

  local manifest_lib
  manifest_lib="$(awk -F': *' '/^library_path:/ {print $2; exit}' "${staging}/manifest.txt")"
  [[ -n "$library_path" ]] || library_path="$manifest_lib"

  if [[ -d "$IMMICH_DIR" && -n "$(ls -A "$IMMICH_DIR" 2>/dev/null)" ]]; then
    (( force )) || die "$IMMICH_DIR is not empty — pass --force to overwrite"
  fi
  if [[ -d "${staging}/library" ]]; then
    [[ -n "$library_path" ]] || die "archive contains library/ but no --library-path given"
    if [[ -d "$library_path" && -n "$(ls -A "$library_path" 2>/dev/null)" ]]; then
      (( force )) || die "$library_path is not empty — pass --force to overwrite"
    fi
  fi

  echo
  echo "${C_BOLD}restore plan${C_RESET}"
  echo "  stack target  : $IMMICH_DIR"
  [[ -d "${staging}/library" ]] && echo "  library target: $library_path"
  echo "  db restore    : $((( from_sql )) && echo 'from SQL dump' || echo 'raw datadir')"
  if (( confirm )); then
    read -r -p "Proceed? [y/N] " ans
    [[ "$ans" =~ ^[Yy]$ ]] || die "aborted by user"
  fi

  if [[ -f "${IMMICH_DIR}/docker-compose.yml" ]] && stack_running; then
    log "stopping existing stack…"
    compose down
  fi

  log "restoring $IMMICH_DIR…"
  mkdir -p "$IMMICH_DIR"
  if (( from_sql )); then
    rm -rf "${staging}/immich/postgres"/*  2>/dev/null || true
  fi
  ( cd "${staging}/immich" && tar -cf - . ) | ( cd "$IMMICH_DIR" && tar -xf - )

  if [[ -d "${staging}/library" ]]; then
    log "restoring library → $library_path"
    mkdir -p "$library_path"
    ( cd "${staging}/library" && tar -cf - . ) | ( cd "$library_path" && tar -xf - )
  fi

  log "starting stack…"
  compose up -d

  if (( from_sql )); then
    log "waiting for Postgres to accept connections…"
    until compose exec -T database pg_isready -U postgres >/dev/null 2>&1; do
      sleep 2
    done

    log "dropping pre-init database…"
    compose exec -T database psql -U postgres -d postgres -c \
      "DROP DATABASE IF EXISTS immich;" >/dev/null

    log "loading SQL dump…"
    gunzip -c "${staging}/postgres-dump.sql.gz" \
      | compose exec -T database psql -U postgres -d postgres >/dev/null
    ok "SQL dump applied"

    log "restarting immich-server to pick up restored DB…"
    compose restart immich-server >/dev/null
  fi

  ok "restore complete"
}

# =============================================================================
#  MAIN — dispatch on the first positional arg.
# =============================================================================
case "${1:-}" in
  backup)   shift; do_backup  "$@";;
  restore)  shift; do_restore "$@";;
  -h|--help|help|"") usage;;
  *) err "unknown command: $1"; usage; exit 2;;
esac

Troubleshooting

Symptom Cause / Fix
could not select driver 'nvidia' on up Container Toolkit not wired into Docker. sudo nvidia-ctk runtime configure --runtime=docker && sudo systemctl restart docker
ML container restart-loops with CUDA … out of memory Chosen CLIP model is too large for your VRAM. Switch to a smaller model in Admin → ML, or disable Smart Search temporarily
Uploads fail at ~1 MB / large videos time out Missing the Advanced block in NPM. Nginx default body size is 1 MB
Live updates / job progress don't appear Websockets toggle is off in NPM proxy host
Postgres won't start: database files are incompatible A bind-mounted datadir from a different PG major. Restore from pg_dumpall into an empty ./postgres/
Let's Encrypt fails Domain doesn't resolve to this host, or port 80 isn't reachable from the internet. Test with dig and an external curl
502 Bad Gateway right after restart immich-server still booting. Wait until (healthy) in docker compose ps
GPU works in nvidia-smi on host, fails in container Toolkit installed but nvidia-ctk runtime configure was never run, or Docker wasn't restarted afterwards
Networks already exist on first up Old leftovers. docker network rm immich_proxy immich_immich_internal then re-up

Handy commands

# Everything healthy?
docker compose ps

# Show what's on each network
docker network inspect immich_proxy           --format '{{range .Containers}}{{.Name}} {{.IPv4Address}}{{"\n"}}{{end}}'
docker network inspect immich_immich_internal --format '{{range .Containers}}{{.Name}} {{.IPv4Address}}{{"\n"}}{{end}}'

# Live GPU + container resource use
nvtop                  # apt install nvtop — best live view
docker stats --no-stream

About those network names

Compose auto-prefixes network names with the project name. Since name: immich is at the top of the compose file, the networks become immich_proxy and immich_immich_internal.


Why these choices

  • One folder, one compose. Backups, migrations, audits — everything is in /opt/docker/immich/. No volumes living elsewhere on the disk.
  • Bind mounts, not Docker volumes. Every byte of state is a real file on the host filesystem. rsync-friendly, tar-friendly, human-readable.
  • Two networks. proxy is the "outside-facing" bridge (NPM + immich-server). immich_internal is the private back-end (redis + postgres + ML + immich-server). Only immich-server straddles both, because it's the only thing that needs to talk in both directions.
  • Networks auto-created. Declared inside the compose file rather than external: true. docker compose up is the only setup command — no docker network create to forget.
  • Container-name DNS via Docker's embedded resolver. NPM proxies to immich_server:2283 — Docker's internal DNS resolves that to the right container with no host port, no host.docker.internal, and no manual IP tracking. Static IPs on the proxy network are still set in the compose file so debugging stays predictable, but the day-to-day config uses names.
  • No host port for Immich. Only port 2283 matters internally; NPM is the only entry point. Smaller attack surface, HTTPS-enforced by default.
  • Pinned exact tags. :v2.7.5, :9.0.4, etc. Updates only happen when you bump the file and pull again — no surprises after a reboot.
  • CUDA ML image (-cuda). With a modern GPU, ML jobs that would take hours on CPU finish in minutes. NVENC makes transcoding effectively free.

Got stuck?

Two fastest places to get help:

  • Distro Domain Discord — drop your docker compose ps output, the failing log line, or your compose file. Real humans, no bots.
  • Comments on the YouTube channel — once the paired video lands, drop questions there too.