#!/usr/bin/env bash # Import the OLD ZimaOS/CasaOS Immich database into the NixOS-managed one. # Run this ON jupiter, as root, ONCE, AFTER the first `./deploy switch jupiter` # that ships services/media/immich.nix (the empty `immich` DB must exist). # # The media files are moved separately — do that FIRST, it is a rename on the # same filesystem, so instant even at 9.1G. Move the CONTENTS, not the dir: # systemd.tmpfiles already created /mnt/data/AppData/immich on the first # deploy, so `mv ` would nest it as .../immich/upload/ and every # thumbnail lookup would ENOENT. # # systemctl stop immich-server immich-machine-learning # mv /mnt/data/Immich/upload/* /mnt/data/AppData/immich/ # chown -R immich:immich /mnt/data/AppData/immich # chmod 700 /mnt/data/AppData/immich # # Expected afterwards: library/ upload/ thumbs/ encoded-video/ profile/ backups/ # # The legacy cluster turned out to be Postgres 14 running VectorChord 0.3.0 + # pgvector 0.8.1 (NOT pgvecto.rs), the same extensions nixpkgs ships — so this # is a plain version-upgrade dump/restore and the smart-search and face # embeddings come across intact. No re-running the ML jobs over the library. # Upstream's accepted VectorChord range is >= 0.3, < 2.0, so 0.3.0 -> 1.1.1 is # a supported jump; the REINDEX at the end is what upstream asks for after a # version change. # # What this script does: # 1. cp -a the legacy PGDATA to a scratch dir (the original is never touched, # never even mounted rw — postgres would replay WAL into it). # 2. Boots that copy under immich's own PG14 image, pinned to the SAME # VectorChord version nixpkgs has (1.1.1), and runs `ALTER EXTENSION # vchord UPDATE` so the catalog matches the loaded library. # 3. Dumps it with the LOCAL pg_dump (17.x) over TCP, not the container's # pg_dump (14.x) — dumping with the newer tool is the supported direction. # 4. Restores into a scratch DB, hands ownership to the immich role, shows # you the row counts, and only swaps it into place after you confirm. # # Afterwards Immich runs its own schema migrations up to 2.7.5 on first start. set -euo pipefail LEGACY="${LEGACY:-/mnt/data/Immich/pg-data}" WORK="${WORK:-/var/tmp/immich-import}" # Pinned to EXACTLY what the legacy cluster records in pg_extension — # vchord 0.3.0 + pgvector 0.8.1 — so the old server reads its own indexes # without any in-place extension upgrade. The target side is vchord 1.1.1 / # pgvector 0.8.2, which is fine: a dump/restore rebuilds every index from # scratch, so only the index DEFINITION has to still be valid there. IMAGE="${IMAGE:-ghcr.io/immich-app/postgres:14-vectorchord0.3.0-pgvector0.8.1}" CTR=immich-legacy-pg PORT="${PORT:-15432}" LEGACY_DB="${LEGACY_DB:-immich}" LEGACY_USER="${LEGACY_USER:-}" # empty = probe for it (see below) STAGING_DB=immich_import die() { echo "error: $*" >&2; exit 1; } step() { echo; echo "== $*"; } [ "$(id -u)" = 0 ] || die "run as root" [ -d "$LEGACY" ] || die "no legacy PGDATA at $LEGACY" command -v podman >/dev/null || die "podman not found" command -v pg_dump >/dev/null || die "pg_dump not found (is postgresql on this host?)" systemctl is-active --quiet postgresql || die "postgresql is not running" # immich must be down: it runs schema migrations at startup, and we are about # to replace the schema underneath it. systemctl stop immich-server immich-machine-learning 2>/dev/null || true step "copying legacy PGDATA -> $WORK/pgdata (original stays untouched)" rm -rf "$WORK"; mkdir -p "$WORK" cp -a "$LEGACY" "$WORK/pgdata" # A crashed cluster leaves this behind; it makes the container refuse to start. rm -f "$WORK/pgdata/postmaster.pid" # The dump runs over TCP (local pg_dump 17 -> published port), and this # cluster's own pg_hba wants a password for host connections — the marketplace # app's POSTGRES_PASSWORD is long gone, and POSTGRES_HOST_AUTH_METHOD only # applies when the image INITIALISES a cluster, not to an existing one. This is # a scratch copy bound to 127.0.0.1 for the length of one dump, so trust it. # REPLACE the file rather than appending: pg_hba is first-match-wins, and the # image's existing scram-sha-256 line would shadow anything added below it. cat > "$WORK/pgdata/pg_hba.conf" <<'EOF' local all all trust host all all 0.0.0.0/0 trust host all all ::/0 trust EOF step "booting Postgres 14 + VectorChord on the copy" podman rm -f "$CTR" 2>/dev/null || true podman run -d --name "$CTR" \ -v "$WORK/pgdata:/var/lib/postgresql/data:Z" \ -p "127.0.0.1:$PORT:5432" \ -e POSTGRES_HOST_AUTH_METHOD=trust \ "$IMAGE" >/dev/null trap 'podman rm -f "$CTR" >/dev/null 2>&1 || true' EXIT for _ in $(seq 1 60); do # No -U: the role is probed for below, and pg_isready only checks that the # postmaster is accepting connections at all. if podman exec "$CTR" pg_isready >/dev/null 2>&1; then ready=1; break; fi sleep 2 done [ "${ready:-}" = 1 ] || { podman logs --tail 30 "$CTR"; die "legacy postgres never became ready"; } # The compose stack's POSTGRES_USER is not recorded anywhere on disk and is NOT # necessarily "postgres" — the ZimaOS/CasaOS marketplace app used "casaos". # pg_isready reports "accepting connections" even for a role that doesn't # exist, so probe for one that can actually log in. if [ -z "$LEGACY_USER" ] || ! podman exec "$CTR" psql -U "$LEGACY_USER" -lqt >/dev/null 2>&1; then for candidate in casaos immich postgres; do if podman exec "$CTR" psql -U "$candidate" -lqt >/dev/null 2>&1; then LEGACY_USER="$candidate" echo ">> legacy superuser role: $LEGACY_USER" break fi done fi [ -n "$LEGACY_USER" ] || die "no usable login role found (tried casaos/immich/postgres) — re-run with LEGACY_USER=" echo ">> databases in the legacy cluster:" podman exec "$CTR" psql -U "$LEGACY_USER" -lqt | cut -d'|' -f1 | sed 's/^/ /' if ! podman exec "$CTR" psql -U "$LEGACY_USER" -lqtA -F'|' | cut -d'|' -f1 | grep -qx "$LEGACY_DB"; then found="$(podman exec "$CTR" psql -U "$LEGACY_USER" -lqtA -F'|' | cut -d'|' -f1 \ | grep -vE '^(template[01]|postgres)$' | grep -v '^$' | head -1)" [ -n "$found" ] || die "no non-system database found — set LEGACY_DB= and re-run" echo ">> database '$LEGACY_DB' not found, using '$found'" LEGACY_DB="$found" fi step "legacy versions" podman exec "$CTR" psql -U "$LEGACY_USER" -d "$LEGACY_DB" \ -c 'select extname, extversion from pg_extension order by extname' # Immich switched from TypeORM to Kysely, so the table changed name; try both. echo ">> last applied immich migration:" podman exec "$CTR" psql -U "$LEGACY_USER" -d "$LEGACY_DB" -tAc \ 'select name from kysely_migration order by timestamp desc limit 1' 2>/dev/null \ || podman exec "$CTR" psql -U "$LEGACY_USER" -d "$LEGACY_DB" -tAc \ 'select name from migrations order by timestamp desc limit 1' 2>/dev/null \ || echo " (neither kysely_migration nor migrations exists — unusual, check the dump)" step "dumping $LEGACY_DB with the local pg_dump ($(pg_dump --version | awk '{print $3}'))" # Embeddings INCLUDED: same extension on both ends, so they restore as-is. pg_dump -h 127.0.0.1 -p "$PORT" -U "$LEGACY_USER" -d "$LEGACY_DB" \ --no-owner --no-acl \ > "$WORK/immich.sql" echo ">> dump: $(du -h "$WORK/immich.sql" | cut -f1)" podman rm -f "$CTR" >/dev/null; trap - EXIT step "restoring into scratch DB $STAGING_DB" sudo -u postgres psql -qc "DROP DATABASE IF EXISTS $STAGING_DB" sudo -u postgres psql -qc "CREATE DATABASE $STAGING_DB OWNER immich" sudo -u postgres psql -qd "$STAGING_DB" \ -c 'CREATE EXTENSION IF NOT EXISTS vector' \ -c 'CREATE EXTENSION IF NOT EXISTS vchord CASCADE' # Not -v ON_ERROR_STOP=1: the pre-created extensions make the dump's own # CREATE EXTENSION lines complain harmlessly. Errors are counted, not hidden. sudo -u postgres psql -d "$STAGING_DB" -f "$WORK/immich.sql" > "$WORK/restore.log" 2>&1 || true echo ">> errors logged: $(grep -c '^ERROR' "$WORK/restore.log" || true) (see $WORK/restore.log)" grep '^ERROR' "$WORK/restore.log" | sort -u | head -10 | sed 's/^/ /' || true step "handing ownership to the immich role" # --no-owner made everything owned by the restoring role (postgres); immich # connects as "immich" and its startup migrations run ALTER TABLE, so it must # own its own schema. NOT `REASSIGN OWNED BY postgres` — that also sweeps up # system objects and fails with "cannot reassign ownership of objects owned by # role postgres because they are required by the database system". Extension- # owned routines/types are excluded for the same reason; immich never alters # those, and they correctly stay with postgres. sudo -u postgres psql -qd "$STAGING_DB" <<'SQL' ALTER SCHEMA public OWNER TO immich; DO $$ DECLARE r record; BEGIN FOR r IN SELECT c.relkind AS kind, n.nspname AS ns, c.relname AS name FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace WHERE n.nspname = 'public' AND c.relkind IN ('r','p','v','m','S','f') LOOP EXECUTE format('ALTER %s %I.%I OWNER TO immich', CASE r.kind WHEN 'S' THEN 'SEQUENCE' WHEN 'v' THEN 'VIEW' WHEN 'm' THEN 'MATERIALIZED VIEW' WHEN 'f' THEN 'FOREIGN TABLE' ELSE 'TABLE' END, r.ns, r.name); END LOOP; FOR r IN SELECT p.oid::regprocedure AS sig FROM pg_proc p JOIN pg_namespace n ON n.oid = p.pronamespace WHERE n.nspname = 'public' AND p.prokind IN ('f','p') AND p.oid NOT IN (SELECT objid FROM pg_depend WHERE deptype = 'e' AND classid = 'pg_proc'::regclass) LOOP EXECUTE format('ALTER ROUTINE %s OWNER TO immich', r.sig); END LOOP; FOR r IN SELECT t.oid::regtype AS name FROM pg_type t JOIN pg_namespace n ON n.oid = t.typnamespace WHERE n.nspname = 'public' AND t.typtype IN ('e','c','d') AND NOT EXISTS (SELECT 1 FROM pg_class c WHERE c.reltype = t.oid AND c.relkind <> 'c') AND t.oid NOT IN (SELECT objid FROM pg_depend WHERE deptype = 'e' AND classid = 'pg_type'::regclass) LOOP EXECUTE format('ALTER TYPE %s OWNER TO immich', r.name); END LOOP; END $$; SQL still_wrong="$(sudo -u postgres psql -tAd "$STAGING_DB" -c \ "select count(*) from pg_class c join pg_namespace n on n.oid = c.relnamespace where n.nspname = 'public' and c.relkind in ('r','p','v','m','S','f') and c.relowner <> 'immich'::regrole")" [ "$still_wrong" = 0 ] || die "$still_wrong objects still not owned by immich — nothing swapped" echo ">> all public objects owned by immich" step "what came across" # Immich 2.x table names (singular, and "user" is a reserved word). The legacy # cluster holds 666 assets / 2 users / 227k geodata rows — expect those back. sudo -u postgres psql -d "$STAGING_DB" -c \ "select 'asset' t, count(*) from asset union all select 'album', count(*) from album union all select 'person', count(*) from person union all select 'user', count(*) from \"user\" union all select 'geodata_places', count(*) from geodata_places union all select 'smart_search', count(*) from smart_search union all select 'face_search', count(*) from face_search" 2>&1 || \ die "staging DB looks wrong — nothing was swapped, inspect $WORK/restore.log" echo echo "The scratch DB is populated. Swapping REPLACES the live (empty) immich DB." read -rp ">> type 'swap' to promote $STAGING_DB to immich: " ok [ "$ok" = swap ] || { echo "left in place as $STAGING_DB — nothing changed"; exit 0; } step "promoting" sudo -u postgres psql -qc "ALTER DATABASE immich RENAME TO immich_empty_$(date +%s)" sudo -u postgres psql -qc "ALTER DATABASE $STAGING_DB RENAME TO immich" step "rebuilding the vector indexes for VectorChord 1.1.1" # Upstream requires a REINDEX after a vchord version change. Skipped silently # if immich named them differently in this schema version. sudo -u postgres psql -d immich -c 'REINDEX INDEX face_index' 2>/dev/null || true sudo -u postgres psql -d immich -c 'REINDEX INDEX clip_index' 2>/dev/null || true step "starting immich (it will run its own migrations up to 2.7.5 now)" systemctl start immich-server immich-machine-learning echo ">> follow with: journalctl -fu immich-server" echo ">> legacy data still intact at /mnt/data/Immich — delete only once happy"