{ config, 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; # ⚠️ Setting `settings` at all switches immich to IMMICH_CONFIG_FILE, and # that is ALL-OR-NOTHING (dist/utils/config.js: the config is # `configFile ? loadFromFile(...) : metadataRepo.get(SystemConfig)` — the # database copy is IGNORED, not merged). Two consequences: # 1. Anything not declared here falls back to immich's DEFAULTS, not to # whatever the admin UI had. The old settings stay in the # system_metadata table, so deleting this block restores them. # 2. The admin settings UI goes read-only — saving throws "Cannot update # configuration while IMMICH_CONFIG_FILE is in use". Change settings # HERE and redeploy. # An unknown/misspelled key is a HARD startup failure under a config file # (the same code path only logs a warning without one), so keys below are # taken verbatim from `defaults` in immich's dist/config.js. settings = { server.externalDomain = "https://immich.mgaction.town"; newVersionCheck.enabled = false; # nixpkgs pins the version, not immich # OIDC via Authentik on neptun. The Authentik application/provider is # created BY HAND in its UI — same as headscale's and headplane's, which # are also separate apps (hosts/neptun/secrets.nix). Only the client # secret is managed here. oauth = { enabled = true; # Authentik's per-application issuer. Trailing slash matters: immich # appends /.well-known/openid-configuration to it. issuerUrl = "https://auth.mgaction.town/application/o/immich/"; # Authentik generates this; not a secret (it is sent in the browser # redirect), so it lives here rather than in sops. clientId = "FxrkWFe8keBrN83tu03UbAshSck52he2GphJDYSQ"; # Read at runtime via systemd LoadCredential — never lands in the # world-readable /nix/store copy of the rendered config. clientSecret._secret = config.sops.secrets.immich_oauth_client_secret.path; scope = "openid email profile"; buttonText = "Login with Authentik"; # Existing accounts (the 2 imported users) keep working: matching is by # email, so an Authentik user with the same address adopts that account # rather than creating a second one. autoRegister = true; # Leave the password form reachable — autoLaunch would bounce straight # to Authentik, locking everyone out if the OIDC app is misconfigured. autoLaunch = false; # Land back on immich's own login page after logout. Without this, # immich falls back to the IdP's discovered end_session_endpoint # (auth.service.js:320-326) and logout dumps you on Authentik's # "you've been logged out" page instead. Must be an ABSOLUTE url — # the config schema rejects a relative path — and mirrors immich's # internal LOGIN_URL, including autoLaunch=0. # # Note this ends the IMMICH session only; the Authentik SSO session # survives, so the next "Login with Authentik" click signs straight # back in without a credential prompt. To end both, drop this line and # let the IdP endpoint take over again. endSessionEndpoint = "https://auth.mgaction.town/application/o/immich/end-session?post_logout_redirect_url=https://immich.mgaction.town"; # The mobile app can't follow a browser redirect back to a custom # scheme through Authentik, so immich bounces it via this endpoint. mobileOverrideEnabled = true; mobileRedirectUri = "https://immich.mgaction.town/api/oauth/mobile-redirect"; }; }; # 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 -" ]; # The unit's automatic RequiresMountsFor covers /run/immich and /var/lib/immich # only — nothing points it at mediaLocation. Without this immich starts with # the array missing and writes uploaded photos onto the 29G eMMC, into a # directory that becomes invisible the moment /mnt/data mounts over it. systemd.services.immich-server.unitConfig.RequiresMountsFor = [ "/mnt/data" ]; }