From 6ce61ab519cc6a93ec73345819e591c2a6183ddb Mon Sep 17 00:00:00 2001 From: Erik Simon Date: Tue, 21 Jul 2026 00:51:10 +0200 Subject: [PATCH] immich: add the service and import the ZimaOS library jupiter had a leftover docker-compose Immich on the RAID (/mnt/data/Immich, 9.9G) that survived the NixOS install. Native module now, media at /mnt/data/AppData/immich, caddy vhost on 2283 with a 50GB body limit (caddy's default rejects video uploads). The package comes from nixpkgs-unstable, the module from the 26.05 pin: 26.05 ships immich 2.7.5, but that database was last written by 3.0.0 and migrations only run forward -- corrupted migrations: previously executed migration 1776217577402-DropAuditTable is missing Safe because the two module files are byte-identical at these revisions; services/media/immich.nix carries the diff command to re-check on a bump. Drop the input once the stable pin ships >= 3.0.0. immich needs group "users" only to traverse /mnt/data/AppData (drwx--x---); its own dir stays 0700 immich:immich. mediaLocation is outside /var/lib, so the module's tmpfiles entry only ADJUSTS it -- add a rule that creates it. scripts/immich-import-legacy-db does the database half: boots a copy of the legacy PGDATA under the matching image (PG14 + vchord 0.3.0 + pgvector 0.8.1), dumps it with the local pg_dump 17, restores into a scratch DB, fixes ownership, and only swaps after confirmation. Never touches the original. The old cluster ran VectorChord, not pgvecto.rs, so the smart search and face embeddings survive -- no ML re-run. Imported: 666 assets, 25 people, 647 clip + 359 face embeddings, 2 users. --- flake.lock | 17 +++ flake.nix | 5 + hosts/jupiter/configuration.nix | 9 ++ scripts/immich-import-legacy-db | 243 ++++++++++++++++++++++++++++++++ services/media/immich.nix | 62 ++++++++ 5 files changed, 336 insertions(+) create mode 100755 scripts/immich-import-legacy-db create mode 100644 services/media/immich.nix diff --git a/flake.lock b/flake.lock index 4b5496c..61a9167 100644 --- a/flake.lock +++ b/flake.lock @@ -263,6 +263,22 @@ "type": "github" } }, + "nixpkgs-unstable": { + "locked": { + "lastModified": 1784555310, + "narHash": "sha256-/FCliTPgiuV1owejZFNx3Ch9irdvkOfOFl+HHZ+DrtM=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "421eebfd0ec7bccd4abe826ce62d7e6e83129493", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixpkgs-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, "nixpkgs_2": { "locked": { "lastModified": 1784280462, @@ -387,6 +403,7 @@ "mediamanager-nix": "mediamanager-nix", "nixos-images": "nixos-images", "nixpkgs": "nixpkgs_2", + "nixpkgs-unstable": "nixpkgs-unstable", "sops-nix": "sops-nix" } }, diff --git a/flake.nix b/flake.nix index 891904e..d178ff7 100644 --- a/flake.nix +++ b/flake.nix @@ -3,6 +3,11 @@ inputs = { nixpkgs.url = "github:NixOS/nixpkgs/nixos-26.05"; + # Second nixpkgs, used for ONE package: immich. 26.05 pins 2.7.5, but + # jupiter's imported database was written by 3.0.0 and immich never + # migrates a schema backwards. NOT `follows` — the point is a different + # package set. See services/media/immich.nix. + nixpkgs-unstable.url = "github:NixOS/nixpkgs/nixpkgs-unstable"; disko = { url = "github:nix-community/disko"; inputs.nixpkgs.follows = "nixpkgs"; diff --git a/hosts/jupiter/configuration.nix b/hosts/jupiter/configuration.nix index 2734c1b..b606795 100644 --- a/hosts/jupiter/configuration.nix +++ b/hosts/jupiter/configuration.nix @@ -20,6 +20,7 @@ ../../services/media/radarr.nix ../../services/media/clonarr.nix ../../services/media/seerr.nix + ../../services/media/immich.nix ../../services/dev/gitea.nix ]; @@ -71,6 +72,14 @@ "http://clonarr.jupiter.sol".extraConfig = "reverse_proxy localhost:6060"; "http://seerr.jupiter.sol".extraConfig = "reverse_proxy localhost:5055"; "http://gitea.jupiter.sol".extraConfig = "reverse_proxy localhost:3000"; + # Immich uploads are large: raise the body limit off caddy's default and + # give slow phone uploads room before the proxy gives up. + "http://immich.jupiter.sol".extraConfig = '' + request_body { + max_size 50GB + } + reverse_proxy localhost:2283 + ''; }; system.stateVersion = "26.05"; diff --git a/scripts/immich-import-legacy-db b/scripts/immich-import-legacy-db new file mode 100755 index 0000000..24b5bc2 --- /dev/null +++ b/scripts/immich-import-legacy-db @@ -0,0 +1,243 @@ +#!/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" diff --git a/services/media/immich.nix b/services/media/immich.nix new file mode 100644 index 0000000..4b072bf --- /dev/null +++ b/services/media/immich.nix @@ -0,0 +1,62 @@ +{ pkgs, inputs, ... }: + +# Immich photo/video library. Native nixpkgs module (not the upstream compose +# stack) — it owns its own postgres (with the pgvector + vectorchord extensions +# it needs for search) and a unix-socket redis, so nothing else is required here. +# +# Storage: everything lives under /mnt/data/AppData/immich, which is the media +# store MIGRATED from the old ZimaOS/CasaOS install's UPLOAD_LOCATION +# (/mnt/data/Immich/upload — same layout: library/ upload/ thumbs/ +# encoded-video/ profile/ backups/). See scripts/immich-import-legacy-db for the +# matching database import. The postgres cluster itself stays on the OS disk. +# +# ⚠️ The immich DB is the only copy of albums/faces/dates — the files alone +# can't rebuild it. It joins the other unbacked databases on this network. +let + # The PACKAGE comes from nixpkgs-unstable (3.0.3); the MODULE comes from the + # 26.05 pin (which ships 2.7.5). That combination is safe because the two + # module files are byte-identical — verified by diffing them at the revisions + # in flake.lock. RE-CHECK THAT DIFF on any input bump: + # diff <(nixpkgs)/nixos/modules/services/web-apps/immich.nix \ + # <(unstable)/nixos/modules/services/web-apps/immich.nix + # + # Why: jupiter's imported database was last written by immich 3.0.0, and + # immich runs its migrations forward only — 2.7.5 refuses to start against it + # with "corrupted migrations: previously executed migration + # 1776217577402-DropAuditTable is missing". Drop this override once nixos-26.11 + # (or whatever the pin becomes) ships >= 3.0.0. + unstable = import inputs.nixpkgs-unstable { + inherit (pkgs.stdenv.hostPlatform) system; + }; +in +{ + services.immich = { + enable = true; + # Both the server and immich-machine-learning follow this: the module takes + # the ML service from cfg.package.machine-learning (passthru). + package = unstable.immich; + # Listens on all interfaces: :2283 stays closed on the LAN (no + # openFirewall), reachable over tailscale0 and via localhost (caddy). + host = "0.0.0.0"; + port = 2283; + mediaLocation = "/mnt/data/AppData/immich"; + machine-learning.enable = true; + # Hardware transcoding would need the iGPU passed in explicitly, e.g. + # accelerationDevices = [ "/dev/dri/renderD128" ]; the default [ ] means + # PrivateDevices=yes and CPU-only transcode. The ZimaBlade's Celeron does + # this slowly but it only runs on upload. + }; + + # /mnt/data/AppData is drwx--x--- darman:users — immich needs group "users" + # just to TRAVERSE into its own media dir. The dir itself stays 0700 + # immich:immich (the module's tmpfiles rule re-asserts that every rebuild, + # and UMask=0077 keeps new files private), so this grants nothing else. + users.users.immich.extraGroups = [ "users" ]; + + # mediaLocation is outside /var/lib, so the module won't create it — its own + # tmpfiles entry only ADJUSTS an existing dir. Harmless no-op after the + # legacy import, which puts the real store here. + systemd.tmpfiles.rules = [ + "d /mnt/data/AppData/immich 0700 immich immich -" + ]; +}