Skip to content

11 — Immich Photo Storage

Self-hosted photo and video library running on the Z2, replacing iCloud Photos as the primary archive. Tailscale-only; deployed as a Docker Compose stack; historical iCloud library bulk-imported via icloudpd + immich-go.

Behind the Caddy reverse proxy (May 2026). Immich's container binds 127.0.0.1:2283 only and is reached at https://immich.z2mini.gabrielgabrie.com (auto-renewing Let's Encrypt cert via Caddy). The iOS app's Server Endpoint URL = https://immich.z2mini.gabrielgabrie.com/api. On the box itself: http://127.0.0.1:2283. The old http://z2mini:2283 / http://100.67.235.68:2283 direct URLs no longer work — apps aren't reachable on the tailnet directly anymore, only through Caddy.

Migrating from iCloud? The full migration runbook is the last section of this page — see Migrating an iCloud Photos library.


Overview

The Immich stack is four containers managed by Docker Compose:

Container Image Purpose
immich_server ghcr.io/immich-app/immich-server Web UI, REST API, background microservices
immich_machine_learning ghcr.io/immich-app/immich-machine-learning:*-cuda Face recognition, smart search, OCR — GPU-accelerated on the Quadro T2000
immich_postgres ghcr.io/immich-app/postgres (vectorchord-enabled) Main DB + vector embeddings
immich_redis docker.io/valkey/valkey:9 Job queue + cache

Two related tools, each with its own page-section below:

  • icloudpd — separate Docker container at /data/docker/icloudpd/. One-shot CLI for downloading from iCloud Photos. Not a long-running service.
  • immich-go — single Go binary at /home/gabriel/scripts/immich-go. Used for bulk-importing files into Immich via its API.

Reachable at https://immich.z2mini.gabrielgabrie.com from any tailnet device. Not exposed to the public internet.


Design decisions

These were considered carefully — reverting any of them needs a real reason.

Decision Reasoning
Immich over PhotoPrism, Ente, Photoview Best iPhone integration, on-server AI/ML, active project momentum (May 2026)
Bound to 127.0.0.1:2283, fronted by Caddy Apps live behind the single Caddy ingress at https://immich.z2mini.gabrielgabrie.com; never reachable on the tailnet directly or publicly. (Was 100.67.235.68:2283 before the Caddy migration.) See 17-caddy.md.
Storage Template Engine ON Library on disk organized by capture date (library/admin/2026/2026-05-03/IMG_*.HEIC); recoverable and browsable even without the DB
Bind-mount ML cache instead of Docker named volume Keeps everything Immich-related under /data/docker/immich/; ML weights (~2-3 GB) live on /data rather than the OS drive
GPU acceleration enabled (May 2026) Quadro T2000 with NVIDIA driver 595 + nvidia-container-toolkit. Pre-signed linux-modules-nvidia-595-generic package bypasses DKMS so no MOK enrollment is needed under Secure Boot. ML container uses the :cuda image variant with extends: hwaccel.ml.yml service: cuda. Peak VRAM ~3.5 / 4 GB with CLIP + face + OCR all loaded; processing the post-ingest ML wave on GPU is roughly 4-5× faster than the CPU baseline
restart: unless-stopped (not always) Survives reboots, but docker compose stop actually stops
iOS auto-backup OFF during bulk migration, ON afterwards iOS only sees photos local to the device — wrong tool for bulk. After migration completes (and after pruning iCloud to a small "keep" set so re-uploads don't undo Immich cleanup), auto-backup becomes the daily-driver: every new iPhone capture pushes to Immich automatically
iCloud Photos cleared post-migration; iCloud subscription downgraded to 200 GB tier ($3.99/mo) Originally the plan was to keep iCloud Photos full as an off-site safety net until a 2 TB external SSD arrived. Decided instead to clear iCloud Photos at the same time as the migration verify, accepting that Immich on /data is the only photo copy until the 2 TB drive arrives. The 50 GB iCloud tier was evaluated and rejected because iMessage (~60 GB after attachment purge) plus the Family Sharing pool (~85 GB) don't fit, and the iPhone (128 GB) can't hold the full Messages history locally either

Evaluated and not chosen: PhotoPrism, Ente, Photoview (all viable; Immich's iPhone story tipped it).


Install

Directory layout

/data/docker/immich/
├── docker-compose.yml         ← stack definition, version-pinned
├── .env                       ← DB password (mode 600), version, paths
├── library/                   ← photos + thumbnails (bind-mounted to container)
├── postgres/                  ← Postgres data (mode 700, uid 999) — NEVER rsync
└── model-cache/               ← ML model weights

docker-compose.yml

The compose file mirrors the upstream Immich v2.7.5 template verbatim except for two intentional customizations:

  1. Port published on 127.0.0.1 only ("127.0.0.1:2283:2283") — Immich is fronted by Caddy at https://immich.z2mini.gabrielgabrie.com, not reachable on the tailnet directly (see 17-caddy.md)
  2. ML cache is a bind mount (${MODEL_CACHE_LOCATION}:/cache) instead of a Docker named volume
name: immich

services:
  immich-server:
    container_name: immich_server
    image: ghcr.io/immich-app/immich-server:${IMMICH_VERSION:-release}
    volumes:
      - ${UPLOAD_LOCATION}:/data
      - /etc/localtime:/etc/localtime:ro
    env_file:
      - .env
    ports:
      - "127.0.0.1:2283:2283"
    depends_on:
      - redis
      - database
    restart: unless-stopped
    healthcheck:
      disable: false

  immich-machine-learning:
    container_name: immich_machine_learning
    image: ghcr.io/immich-app/immich-machine-learning:${IMMICH_VERSION:-release}
    volumes:
      - ${MODEL_CACHE_LOCATION}:/cache
    env_file:
      - .env
    restart: unless-stopped
    healthcheck:
      disable: false

  redis:
    container_name: immich_redis
    image: docker.io/valkey/valkey:9@sha256:3b55fbaa0cd93cf0d9d961f405e4dfcc70efe325e2d84da207a0a8e6d8fde4f9
    healthcheck:
      test: redis-cli ping || exit 1
    restart: unless-stopped

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

Important: do not add a command: override to the database: service. The postgres image already configures shared_preload_libraries=vchord.so itself; an override clobbers that and immich-server crash-loops on CREATE EXTENSION vchord.

.env

# Path config
UPLOAD_LOCATION=/data/docker/immich/library
DB_DATA_LOCATION=/data/docker/immich/postgres
MODEL_CACHE_LOCATION=/data/docker/immich/model-cache

# Version pin — verify current stable:
#   curl -s https://api.github.com/repos/immich-app/immich/releases/latest | grep tag_name
IMMICH_VERSION=v2.7.5

# Timezone
TZ=America/Toronto

# Database
DB_PASSWORD=<48-char hex string from `openssl rand -hex 24`>
DB_USERNAME=postgres
DB_DATABASE_NAME=immich

Generate the DB password directly into the file rather than typing it:

DB_PASS=$(openssl rand -hex 24)
# ... write .env using $DB_PASS in the heredoc, then:
chmod 600 /data/docker/immich/.env

First boot

cd /data/docker/immich
docker compose config --quiet      # validate YAML + variable resolution
docker compose pull                # ~6 GB across four images
docker compose up -d

Then poll for health (postgres initdb ~30 s, immich-server migrations ~30-60 s):

docker compose ps

When all four containers report (healthy), the stack is ready. Quick HTTP check:

curl -sS http://127.0.0.1:2283/api/server/ping
# → {"res":"pong"}

First-run admin account

Open https://immich.z2mini.gabrielgabrie.com in your browser, set the admin email and password (separate from any other credentials — Immich has its own user DB).

Decisions to make on the first-run wizard:

  • Storage Template Engine: ON. Files get organized by capture date on disk. Flipping this later moves every file — set it before importing anything substantial.
  • Google Cast: OFF. Only useful with a Cast device on the same LAN. Toggle on later if needed.

Operations

Start / stop / pull updates

cd /data/docker/immich
docker compose up -d           # start (or recreate after pull)
docker compose stop            # stop without removing
docker compose down            # stop and remove containers (data preserved)

# Updates: bump IMMICH_VERSION in .env, then:
docker compose pull
docker compose up -d

Logs

docker compose logs -f --tail 50 immich-server
docker compose logs -f --tail 50 immich-machine-learning
docker compose logs -f --tail 50 database

Disk usage

du -sh /data/docker/immich/library/ \
       /data/docker/immich/postgres/ \
       /data/docker/immich/model-cache/

Permission denied on postgres/ is expected — it's mode 700 owned by uid 999 (the postgres container user). Use sudo du or run from inside the container if you need to peek.

Total asset count via API

curl -sH "x-api-key: $(cat ~/.immich-api-key)" \
     http://127.0.0.1:2283/api/server/statistics | python3 -m json.tool

API keys

Account avatar (top right) → Account SettingsAPI KeysNew API Key. The key is shown once — save it immediately:

umask 077 && read -s -p "Paste API key: " K \
  && printf '%s\n' "$K" > ~/.immich-api-key \
  && unset K \
  && ls -la ~/.immich-api-key

The file ends up at mode 600 because of the umask 077.

Connecting from on the server itself

Immich's container binds 127.0.0.1:2283, so scripts/tools running on z2mini use http://127.0.0.1:2283 (or http://localhost:2283). https://immich.z2mini.gabrielgabrie.com also works from the box (resolves to 100.67.235.68, goes through Caddy). What no longer works: http://z2mini:2283 and http://100.67.235.68:2283 — those bindings were removed when Immich moved behind Caddy. (Pre-Caddy, the opposite was true — Immich bound the tailnet IP and localhost didn't work; that's flipped now.)


iOS app setup

  1. Confirm Tailscale on the iPhone is connected (Tailscale iOS app → toggle should say "Connected").
  2. Smoke-test reachability in Safari first: https://immich.z2mini.gabrielgabrie.com should load the login page.
  3. Open the Immich app → Log In (or whatever the entry button is in your app version).
  4. Server Endpoint URL: https://immich.z2mini.gabrielgabrie.com/api
  5. Sign in with your admin email and password.
  6. Photos permission: Allow Access to All Photosnot "Selected Photos." Selected mode hides the rest of your library from the app.
  7. Don't enable auto-backup yet if you're about to do a bulk migration — see the next section.

Migrating an iCloud Photos library

The bulk migration uses two independent tools chained together:

iCloud  →  icloudpd container  →  /data/icloud-import/YYYY/MM/DD/  →  immich-go  →  Immich library
                                  (transient staging,                              (storage template applies)
                                   deleted after verify)

Why not just use the iOS app's auto-backup? iOS only sees photos local to the device. With "Optimize iPhone Storage" on (the default), the bulk of any sizeable library lives in iCloud, not on the phone — so iOS auto-backup would only migrate a small subset. The bulk migration has to come from iCloud's web API directly.

Pre-flight: iCloud account requirements

icloudpd authenticates against Apple's iCloud Photos API. Two account settings must be true:

  • Advanced Data Protection: OFF. With ADP enabled, icloudpd hard-fails with ACCESS_DENIED. If you turned ADP off specifically for the migration, you can re-enable it once the bulk download is done. If it was already off, this is a non-issue.
  • "Access iCloud Data on the Web": ON. Settings → [Your Name] → iCloud → scroll down.

icloudpd setup

Layout:

/data/docker/icloudpd/
├── docker-compose.yml         ← image pin + bind mounts
└── cookies/                   ← Apple session token (root-owned, ~2 month validity)

docker-compose.yml:

name: icloudpd

services:
  icloudpd:
    image: icloudpd/icloudpd:1.32.2
    container_name: icloudpd
    volumes:
      - /data/docker/icloudpd/cookies:/cookies
      - /data/icloud-import:/data
    environment:
      - TZ=America/Toronto

There's no up -d for icloudpd. It's invoked on demand with docker compose run --rm for each session.

First run — interactive auth + smoke test

The first invocation is interactive: Apple ID password and 2FA code. The cookie this seeds is then valid for ~2 months.

cd /data/docker/icloudpd
docker compose run --rm icloudpd icloudpd \
  --directory /data \
  --username your-apple-id@example.com \
  --cookie-directory /cookies \
  --recent 5 \
  --size original \
  --live-photo-size original

--recent 5 downloads only the 5 most recent items — fast smoke test that validates auth, cookie persistence, AND the file landing path (/data/icloud-import/YYYY/MM/DD/).

Subsequent runs reuse the cookie automatically — no auth prompts unless Apple invalidates the session.

Bulk download with watch-mode auto-retry

Apple's iCloud Photos API intermittently throttles or disconnects (icloudpd issue #1308). When this happens mid-download, icloudpd exits cleanly with INFO Cannot connect to Apple iCloud service and exit code 0 — which is not the same as "completed."

The mitigation is icloudpd's --watch-with-interval 60 flag — runs in an infinite loop with a 60-second sleep between passes. Each new pass picks up exactly where the previous one left off. Once the library is fully downloaded, subsequent passes are quick no-ops.

Wrapped in a script at /home/gabriel/scripts/icloudpd-bulk-download.sh:

#!/bin/bash
set -o pipefail
BULK_LOG=/tmp/icloudpd-bulk.log
echo "" | tee -a "$BULK_LOG"
echo "================================================================" | tee -a "$BULK_LOG"
echo "  iCloud bulk download (watch mode) started: $(date '+%Y-%m-%d %H:%M:%S %Z')" | tee -a "$BULK_LOG"
echo "================================================================" | tee -a "$BULK_LOG"
cd /data/docker/icloudpd
docker compose run --rm icloudpd icloudpd \
  --directory /data \
  --username your-apple-id@example.com \
  --cookie-directory /cookies \
  --size original \
  --live-photo-size original \
  --set-exif-datetime \
  --watch-with-interval 60 \
  --no-progress-bar 2>&1 | tee -a "$BULK_LOG"

Launch in a detached tmux session so it survives ssh disconnects:

tmux new -d -s icloud-migration /home/gabriel/scripts/icloudpd-bulk-download.sh

Monitor:

tail -f /tmp/icloudpd-bulk.log
find /data/icloud-import -type f | wc -l
du -sh /data/icloud-import

Reattach (read-only-ish — careful not to type into the session):

tmux attach -t icloud-migration

Detach again with Ctrl+B then D.

For a 31k-item / ~458 GB library, expect 2-5 days at Apple's API throttle.

Stop the watch loop when done

--watch-with-interval never exits on its own. Decide the bulk is complete when:

  • File count under /data/icloud-import/ matches your iCloud library size and stops growing across multiple watch cycles, AND
  • The bulk log shows nothing but already exists lines for several minutes

Then kill the session:

tmux kill-session -t icloud-migration

Mind the disk during the ingest — both copies live on the same filesystem

Lesson from this homelab's first migration (May 2026): during ingest, the photo bytes exist twice on /data — once as the icloudpd staging tree (/data/icloud-import/, 458 GB) and once as Immich's managed copy under /data/docker/immich/library/ (also ~458 GB plus thumbnails). On the Z2 Mini's 938 GB /data filesystem this fits, barely, but at the 86%-uploaded mark /data hit 100% and Postgres started failing every write with could not extend file ... No space left on device — surfacing as 500 Internal Server Errors on every immich-go upload, eventually triggering postgres + immich-server container restarts.

Mitigations to apply BEFORE running the bulk ingest: - Confirm df -h /data shows enough free space for the combined staging + library + thumbnails footprint, not just one copy. Aim for at least 1.3× the source library size in free space. - If staging would exceed available headroom, stage on a different filesystem (e.g., the planned /mnt/scratch partition once the OS-drive shrink is done — see 10-system-reference.md). - Or delete-as-you-go: after each immich-go batch (e.g., per year), parse ~/.cache/immich-go/*.log for INF (uploaded successfully|server has duplicate) lines and rm the corresponding files from staging. Reclaims space incrementally. - During long migrations, watch df -h /data like you'd watch GPU during ML — Immich's job queues will keep filling and ML keeps trying to process even with the disk full, masking the underlying capacity issue.

If you do hit the disk-full wall: the recovery is to parse immich-go's logs for confirmed-successful uploads, build a delete list, sudo rm those files (they're root-owned because icloudpd runs as root in its container), then re-run immich-go on whatever's left in staging. See the troubleshooting section below for the exact pattern.

Ingesting into Immich with immich-go

Once the bulk download is done, ingest the staging directory into Immich. Use immich-go upload from-foldernot from-icloud. The from-icloud subcommand is for Apple's iCloud-takeout zip files (the Privacy Portal export), not icloudpd's output.

Generate an Immich admin API key (Account Settings → API Keys → New) and save it to ~/.immich-api-key mode 600 as shown earlier.

Recommended flags for resilience (learned from the May 2026 migration):

  • --on-errors continue — don't bail on the first transient 500. The default stop policy will abort the whole run if any single upload fails; with continue, immich-go logs the error and moves on, and you can retry just the failed files in a later pass.
  • --concurrent-tasks 4 — lower than the default 20. Reduces server-side load on Postgres + ML and tends to fail less under contention.
  • --pause-immich-jobs=false — by default, immich-go pauses Immich's background jobs during the upload to avoid contention. For long ingests, leave them running so the ML / thumbnail / metadata queues drain in parallel with the upload phase.

Always dry-run first:

/home/gabriel/scripts/immich-go upload from-folder \
  --server http://127.0.0.1:2283 \
  --api-key "$(cat ~/.immich-api-key)" \
  --manage-heic-jpeg StackCoverJPG \
  --no-ui \
  --dry-run \
  /data/icloud-import

The dry-run reports counts: how many would upload, how many are already in Immich (deduplicated by content hash), how many errors. If clean, drop --dry-run and run for real.

Verify and clean up

After immich-go reports success:

  1. Check the Immich web UI count matches expectations — find /data/icloud-import -type f | wc -l should roughly equal total assets in Immich's API stats.
  2. Spot-check a handful of photos in the timeline — thumbnails generated, faces detected, smart search returning sensible results.
  3. Then delete the staging directory (it's served its purpose):
    rm -rf /data/icloud-import
    

Post-migration

  • If you turned off Advanced Data Protection on iCloud specifically for icloudpd auth, re-enable it now. Skip this step if it was already off.
  • Enable iOS Immich app auto-backup for ongoing iPhone captures (Settings → Backup).
  • Consider keeping the icloudpd compose around as an incremental sync mechanism (--until-found N flag) — useful as a "iCloud → Immich one-way mirror" if you keep iCloud Photos partially active.
  • Clear iCloud Photos and downgrade your iCloud subscription. Don't assume the 50 GB tier is the right target — what your iCloud actually holds besides Photos (iMessage history + attachments, Family Sharing pool, device backups, iCloud Drive) often dwarfs 50 GB. Check Settings → [Your Name] → iCloud → Manage Account Storage to see the breakdown before picking a tier. For Gabriel: post-migration usage was ~150 GB (iMessage ~60 GB even after aggressive attachment pruning, Family Sharing pool ~85 GB, iPhone Backup ~20 GB), so the 200 GB tier ($3.99/mo) was the right landing point.
  • Be deliberate about the off-site safety net. Immich now has an on-site backup (/mnt/backup/current/immich/ + the .sql.gz DB dumps — see Backup considerations below), so a deleted-from-Immich or drive-failure scenario is covered. But the photo library is not on the off-site drive (~520 GB > the 500 GB off-site T5), so a fire/theft scenario that takes out both the apartment's drives still loses the photos. Closing that gap needs a >1 TB off-site drive — until then it's a known, accepted risk. (iCloud Photos was cleared at the May 2026 downgrade — see Decisions table — so it's not the safety net anymore either.)

Backup considerations

Immich IS in the nightly backup (since May 2026)

Once the backup drive was upgraded to a 1 TB SSD, Immich was wired into ~/scripts/backup-files.sh (see 05-backups.md). Two pieces:

1. The photo files — the entire UPLOAD_LOCATION (/data/docker/immich/library/) is rsync-mirrored to /mnt/backup/current/immich/ every night at 03:00, with --delete. That covers library/ (~254 GB original photos), upload/ (~188 GB app uploads), encoded-video/ (~68 GB transcodes — included, deliberately), thumbs/ (~7.6 GB), backups/ (Immich's own DB dumps — see below), profile/. The rsync uses --no-owner --no-group because library/ files are owned by Immich's container uid, not gabriel — on restore you re-apply ownership or let Immich fix it.

2. The PostgreSQL database — via Immich's own built-in nightly auto-backup, NOT a filesystem copy. The postgres/ data dir is owned by the container's uid and a live Postgres data dir rsync'd as a snapshot is corrupt — so it is never filesystem-rsync'd. Instead, Immich's own database auto-backup (Admin → Settings → Backup Settings) runs at 02:00, keeps the last 14, and writes compressed dumps named immich-db-backup-YYYYMMDDTHHMMSS-v<ver>-pg<pgver>.sql.gz (~137 MB each) into library/backups/. The 03:00 current/immich/ rsync then picks those up — so they land at /mnt/backup/current/immich/backups/. The chain is: Immich dumps → /data/docker/immich/library/backups/ (02:00) → backup-files.sh rsyncs that into /mnt/backup/current/immich/backups/ (03:00).

The Immich "Database Backups" setting must stay enabled. It's ON by default — leave it on. If you ever turn it off, the photo files keep being backed up but the database stops being captured, a silent gap.

What's NOT backed up

  • /data/docker/immich/postgres/ — covered by the .sql.gz dumps instead (above). Never filesystem-rsync a live Postgres data dir.
  • /data/docker/immich/model-cache/ — ML model weights (~2-3 GB); re-downloaded automatically by the ML container, no need to back it up.

Restore

Follow Immich's official backup/restore docs (immich.app/docs/administration/backup-and-restore):

  1. Restore the photo files: rsync -a /mnt/backup/current/immich/ /data/docker/immich/library/ (the .sql.gz dumps come along inside library/backups/). Re-apply ownership if Immich complains.
  2. Restore the config (.env, docker-compose.yml, docker-compose.yml.pre-cuda, hwaccel.ml.yml) — these are in /mnt/backup/current/service-config/immich/.
  3. Bring up the stack, but recreate the Postgres database from the most recent .sql.gz dump before starting immich-server: recreate the DB, create the required extensions, load the dump (per the Immich docs above). model-cache/ rebuilds itself on first ML run.

See 08-recovery.md → Step 6b for the full disaster-recovery walkthrough.

Off-site: the photo library is not on the off-site drive — at ~520 GB it doesn't fit on the 500 GB off-site T5 (backup-offsite.sh covers documents, music, the DB dumps, and every service's config, but skips current/immich/). So the photos have copies only at the apartment (/data live + /mnt/backup backup) — a known, accepted gap until a >1 TB off-site drive exists. See 05-backups.md.


Troubleshooting

"vchord must be loaded via shared_preload_libraries" — immich-server in crash loop:

  • Cause: a command: override on the database: service is replacing the postgres image's built-in shared_preload_libraries=vchord.so setting.
  • Fix: remove the command: directive from the database: service in compose. The ghcr.io/immich-app/postgres image configures itself; do not override.

icloudpd exits cleanly after a few minutes with "Cannot connect to Apple iCloud service":

  • Cause: Apple's API throttling or transient disconnect (icloudpd #1308).
  • Fix: use --watch-with-interval 60 (already baked into icloudpd-bulk-download.sh). Subsequent passes resume from where the previous left off.

icloudpd fails immediately with ACCESS_DENIED:

  • Cause: Advanced Data Protection enabled on the Apple ID.
  • Fix: Settings → [Your Name] → iCloud → disable Advanced Data Protection. If you turned it off only for the migration, re-enable it once the bulk download completes.

immich-go reports duplicates for photos that look different in the iOS app:

  • Cause: content-hash dedup is by exact bytes. iOS share-sheet uploads convert HEIC → JPG; icloudpd downloads originals. Same visual photo, different bytes, not deduplicated.
  • Fix: post-hoc stack with immich-go stack if duplicate UI entries are bothering you. Otherwise leave them.

Storage Template doesn't reorganize files:

  • Cause: Storage Template wasn't enabled on first-run, OR you uploaded files via External Library mode (which doesn't move files).
  • Fix: Administration → Settings → Storage Template → enable, then run the storage-template-migration job.

A script on z2mini can't reach Immich (or it uses an old http://z2mini:2283 URL):

  • Cause: Immich now binds 127.0.0.1:2283 only (it moved behind Caddy) — http://z2mini:2283 and http://100.67.235.68:2283 no longer exist.
  • Fix: use http://127.0.0.1:2283 (or http://localhost:2283), or https://immich.z2mini.gabrielgabrie.com (via Caddy).

Mid-migration: every immich-go upload starts returning 500 Internal Server Error after some hours of successful progress:

  • Cause: /data is full. Postgres can't write (could not extend file ... No space left on device), so every API call that creates a DB row fails. immich-server log will show repeated Request aborted warnings and PostgresError: ... No space left on device.
  • Fix:
  • Stop the immich-go run: pkill -f "immich-go.*upload from-folder"
  • Identify staging files that are confirmed in Immich already (and therefore safe to delete) by parsing immich-go's log:
    LOGS=/home/gabriel/.cache/immich-go/*.log
    grep -hE "INF (uploaded successfully|server has duplicate)" $LOGS \
      | sed -nE 's|.*file=icloud-import:([^ ]+).*|/data/icloud-import/\1|p' \
      | sort -u > /tmp/safe-to-delete.txt
    wc -l /tmp/safe-to-delete.txt
    
  • Delete those (they're root-owned because icloudpd runs as root in its container):
    sudo xargs -a /tmp/safe-to-delete.txt -d "\n" rm -f
    sudo find /data/icloud-import -mindepth 1 -type d -empty -delete
    
  • Verify df -h /data recovered to a healthy level.
  • Postgres will recover on its own (it may have been container-restarted by Docker's healthcheck during the no-space window — also normal). immich-server restarts shortly after.
  • Re-run immich-go with --on-errors continue --concurrent-tasks 4 --pause-immich-jobs=false to pick up whatever's left.

Pull is slow / fails on Wi-Fi:

  • Cause: typical residential Wi-Fi is the bottleneck for the 6 GB initial pull.
  • Fix: docker compose pull is resumable — re-running picks up partially-downloaded layers. If Apple's iCloud throttle kicks in during a migration, the watch loop handles it; for the initial Immich pull, just retry.

See also

  • 05-backups.md — the nightly backup; Immich's library/ is mirrored to /mnt/backup/current/immich/ and its DB is captured via the library/backups/ dump chain
  • 08-recovery.md — disaster recovery, including the per-service restore (Step 6b)
  • 10-system-reference.md — quick lookup for paths, ports, services
  • z2mini-context-for-ai.md — the AI-context document, kept in sync with this page