1{
2 config,
3 lib,
4 pkgs,
5 ...
6}:
7let
8 cfg = config.services.immich;
9 format = pkgs.formats.json { };
10 isPostgresUnixSocket = lib.hasPrefix "/" cfg.database.host;
11 isRedisUnixSocket = lib.hasPrefix "/" cfg.redis.host;
12
13 commonServiceConfig = {
14 Type = "simple";
15 Restart = "on-failure";
16 RestartSec = 3;
17
18 # Hardening
19 CapabilityBoundingSet = "";
20 NoNewPrivileges = true;
21 PrivateUsers = true;
22 PrivateTmp = true;
23 PrivateDevices = cfg.accelerationDevices == [ ];
24 DeviceAllow = mkIf (cfg.accelerationDevices != null) cfg.accelerationDevices;
25 PrivateMounts = true;
26 ProtectClock = true;
27 ProtectControlGroups = true;
28 ProtectHome = true;
29 ProtectHostname = true;
30 ProtectKernelLogs = true;
31 ProtectKernelModules = true;
32 ProtectKernelTunables = true;
33 RestrictAddressFamilies = [
34 "AF_INET"
35 "AF_INET6"
36 "AF_UNIX"
37 ];
38 RestrictNamespaces = true;
39 RestrictRealtime = true;
40 RestrictSUIDSGID = true;
41 UMask = "0077";
42 };
43 inherit (lib)
44 types
45 mkIf
46 mkOption
47 mkEnableOption
48 ;
49
50 postgresqlPackage =
51 if cfg.database.enable then config.services.postgresql.package else pkgs.postgresql;
52in
53{
54 options.services.immich = {
55 enable = mkEnableOption "Immich";
56 package = lib.mkPackageOption pkgs "immich" { };
57
58 mediaLocation = mkOption {
59 type = types.path;
60 default = "/var/lib/immich";
61 description = "Directory used to store media files. If it is not the default, the directory has to be created manually such that the immich user is able to read and write to it.";
62 };
63 environment = mkOption {
64 type = types.submodule { freeformType = types.attrsOf types.str; };
65 default = { };
66 example = {
67 IMMICH_LOG_LEVEL = "verbose";
68 };
69 description = ''
70 Extra configuration environment variables. Refer to the [documentation](https://immich.app/docs/install/environment-variables) for options tagged with 'server', 'api' or 'microservices'.
71 '';
72 };
73 secretsFile = mkOption {
74 type = types.nullOr (
75 types.str
76 // {
77 # We don't want users to be able to pass a path literal here but
78 # it should look like a path.
79 check = it: lib.isString it && lib.types.path.check it;
80 }
81 );
82 default = null;
83 example = "/run/secrets/immich";
84 description = ''
85 Path of a file with extra environment variables to be loaded from disk. This file is not added to the nix store, so it can be used to pass secrets to immich. Refer to the [documentation](https://immich.app/docs/install/environment-variables) for options.
86
87 To set a database password set this to a file containing:
88 ```
89 DB_PASSWORD=<pass>
90 ```
91 '';
92 };
93 host = mkOption {
94 type = types.str;
95 default = "localhost";
96 description = "The host that immich will listen on.";
97 };
98 port = mkOption {
99 type = types.port;
100 default = 2283;
101 description = "The port that immich will listen on.";
102 };
103 openFirewall = mkOption {
104 type = types.bool;
105 default = false;
106 description = "Whether to open the immich port in the firewall";
107 };
108 user = mkOption {
109 type = types.str;
110 default = "immich";
111 description = "The user immich should run as.";
112 };
113 group = mkOption {
114 type = types.str;
115 default = "immich";
116 description = "The group immich should run as.";
117 };
118
119 settings = mkOption {
120 default = null;
121 description = ''
122 Configuration for Immich.
123 See <https://immich.app/docs/install/config-file/> or navigate to
124 <https://my.immich.app/admin/system-settings> for
125 options and defaults.
126 Setting it to `null` allows configuring Immich in the web interface.
127 '';
128 type = types.nullOr (
129 types.submodule {
130 freeformType = format.type;
131 options = {
132 newVersionCheck.enabled = mkOption {
133 type = types.bool;
134 default = false;
135 description = ''
136 Check for new versions.
137 This feature relies on periodic communication with github.com.
138 '';
139 };
140 server.externalDomain = mkOption {
141 type = types.str;
142 default = "";
143 description = "Domain for publicly shared links, including `http(s)://`.";
144 };
145 };
146 }
147 );
148 };
149
150 machine-learning = {
151 enable =
152 mkEnableOption "immich's machine-learning functionality to detect faces and search for objects"
153 // {
154 default = true;
155 };
156 environment = mkOption {
157 type = types.submodule { freeformType = types.attrsOf types.str; };
158 default = { };
159 example = {
160 MACHINE_LEARNING_MODEL_TTL = "600";
161 };
162 description = ''
163 Extra configuration environment variables. Refer to the [documentation](https://immich.app/docs/install/environment-variables) for options tagged with 'machine-learning'.
164 '';
165 };
166 };
167
168 accelerationDevices = mkOption {
169 type = types.nullOr (types.listOf types.str);
170 default = [ ];
171 example = [ "/dev/dri/renderD128" ];
172 description = ''
173 A list of device paths to hardware acceleration devices that immich should
174 have access to. This is useful when transcoding media files.
175 The special value `[ ]` will disallow all devices using `PrivateDevices`. `null` will give access to all devices.
176 '';
177 };
178
179 database = {
180 enable =
181 mkEnableOption "the postgresql database for use with immich. See {option}`services.postgresql`"
182 // {
183 default = true;
184 };
185 enableVectorChord =
186 mkEnableOption "the new VectorChord extension for full-text search in Postgres"
187 // {
188 default = true;
189 };
190 enableVectors =
191 mkEnableOption "pgvecto.rs in the database. You may disable this, if you have migrated to VectorChord and deleted the `vectors` schema."
192 // {
193 default = lib.versionOlder config.system.stateVersion "25.11";
194 defaultText = lib.literalExpression "lib.versionOlder config.system.stateVersion \"25.11\"";
195 };
196 createDB = mkEnableOption "the automatic creation of the database for immich." // {
197 default = true;
198 };
199 name = mkOption {
200 type = types.str;
201 default = "immich";
202 description = "The name of the immich database.";
203 };
204 host = mkOption {
205 type = types.str;
206 default = "/run/postgresql";
207 example = "127.0.0.1";
208 description = "Hostname or address of the postgresql server. If an absolute path is given here, it will be interpreted as a unix socket path.";
209 };
210 port = mkOption {
211 type = types.port;
212 default = 5432;
213 description = "Port of the postgresql server.";
214 };
215 user = mkOption {
216 type = types.str;
217 default = "immich";
218 description = "The database user for immich.";
219 };
220 };
221 redis = {
222 enable = mkEnableOption "a redis cache for use with immich" // {
223 default = true;
224 };
225 host = mkOption {
226 type = types.str;
227 default = config.services.redis.servers.immich.unixSocket;
228 defaultText = lib.literalExpression "config.services.redis.servers.immich.unixSocket";
229 description = "The host that redis will listen on.";
230 };
231 port = mkOption {
232 type = types.port;
233 default = 0;
234 description = "The port that redis will listen on. Set to zero to disable TCP.";
235 };
236 };
237 };
238
239 config = mkIf cfg.enable {
240 assertions = [
241 {
242 assertion = !isPostgresUnixSocket -> cfg.secretsFile != null;
243 message = "A secrets file containing at least the database password must be provided when unix sockets are not used.";
244 }
245 {
246 # When removing this assertion, please adjust the nixosTests accordingly.
247 assertion =
248 (cfg.database.enable && cfg.database.enableVectors)
249 -> lib.versionOlder config.services.postgresql.package.version "17";
250 message = "Immich doesn't support PostgreSQL 17+ when using pgvecto.rs. Consider disabling it using services.immich.database.enableVectors if it is not needed anymore.";
251 }
252 {
253 assertion = cfg.database.enable -> (cfg.database.enableVectorChord || cfg.database.enableVectors);
254 message = "At least one of services.immich.database.enableVectorChord and services.immich.database.enableVectors has to be enabled.";
255 }
256 ];
257
258 services.postgresql = mkIf cfg.database.enable {
259 enable = true;
260 ensureDatabases = mkIf cfg.database.createDB [ cfg.database.name ];
261 ensureUsers = mkIf cfg.database.createDB [
262 {
263 name = cfg.database.user;
264 ensureDBOwnership = true;
265 ensureClauses.login = true;
266 }
267 ];
268 extensions =
269 ps:
270 lib.optionals cfg.database.enableVectors [ ps.pgvecto-rs ]
271 ++ lib.optionals cfg.database.enableVectorChord [
272 ps.pgvector
273 ps.vectorchord
274 ];
275 settings = {
276 shared_preload_libraries =
277 lib.optionals cfg.database.enableVectors [
278 "vectors.so"
279 ]
280 ++ lib.optionals cfg.database.enableVectorChord [ "vchord.so" ];
281 search_path = "\"$user\", public, vectors";
282 };
283 };
284 systemd.services.postgresql-setup.serviceConfig.ExecStartPost =
285 let
286 extensions = [
287 "unaccent"
288 "uuid-ossp"
289 "cube"
290 "earthdistance"
291 "pg_trgm"
292 ]
293 ++ lib.optionals cfg.database.enableVectors [
294 "vectors"
295 ]
296 ++ lib.optionals cfg.database.enableVectorChord [
297 "vector"
298 "vchord"
299 ];
300 sqlFile = pkgs.writeText "immich-pgvectors-setup.sql" ''
301 ${lib.concatMapStringsSep "\n" (ext: "CREATE EXTENSION IF NOT EXISTS \"${ext}\";") extensions}
302
303 ALTER SCHEMA public OWNER TO ${cfg.database.user};
304 ${lib.optionalString cfg.database.enableVectors "ALTER SCHEMA vectors OWNER TO ${cfg.database.user};"}
305 GRANT SELECT ON TABLE pg_vector_index_stat TO ${cfg.database.user};
306
307 ${lib.concatMapStringsSep "\n" (ext: "ALTER EXTENSION \"${ext}\" UPDATE;") extensions}
308 '';
309 in
310 [
311 ''
312 ${lib.getExe' postgresqlPackage "psql"} -d "${cfg.database.name}" -f "${sqlFile}"
313 ''
314 ];
315
316 services.redis.servers = mkIf cfg.redis.enable {
317 immich = {
318 enable = true;
319 port = cfg.redis.port;
320 bind = mkIf (!isRedisUnixSocket) cfg.redis.host;
321 };
322 };
323
324 networking.firewall.allowedTCPPorts = mkIf cfg.openFirewall [ cfg.port ];
325
326 services.immich.environment =
327 let
328 postgresEnv =
329 if isPostgresUnixSocket then
330 { DB_URL = "postgresql:///${cfg.database.name}?host=${cfg.database.host}"; }
331 else
332 {
333 DB_HOSTNAME = cfg.database.host;
334 DB_PORT = toString cfg.database.port;
335 DB_DATABASE_NAME = cfg.database.name;
336 DB_USERNAME = cfg.database.user;
337 };
338 redisEnv =
339 if isRedisUnixSocket then
340 { REDIS_SOCKET = cfg.redis.host; }
341 else
342 {
343 REDIS_PORT = toString cfg.redis.port;
344 REDIS_HOSTNAME = cfg.redis.host;
345 };
346 in
347 postgresEnv
348 // redisEnv
349 // {
350 IMMICH_HOST = cfg.host;
351 IMMICH_PORT = toString cfg.port;
352 IMMICH_MEDIA_LOCATION = cfg.mediaLocation;
353 IMMICH_MACHINE_LEARNING_URL = "http://localhost:3003";
354 }
355 // lib.optionalAttrs (cfg.settings != null) {
356 IMMICH_CONFIG_FILE = "${format.generate "immich.json" cfg.settings}";
357 };
358
359 services.immich.machine-learning.environment = {
360 MACHINE_LEARNING_WORKERS = "1";
361 MACHINE_LEARNING_WORKER_TIMEOUT = "120";
362 MACHINE_LEARNING_CACHE_FOLDER = "/var/cache/immich";
363 XDG_CACHE_HOME = "/var/cache/immich";
364 IMMICH_HOST = "localhost";
365 IMMICH_PORT = "3003";
366 };
367
368 systemd.slices.system-immich = {
369 description = "Immich (self-hosted photo and video backup solution) slice";
370 documentation = [ "https://immich.app/docs" ];
371 };
372
373 systemd.services.immich-server = {
374 description = "Immich backend server (Self-hosted photo and video backup solution)";
375 requires = lib.mkIf cfg.database.enable [ "postgresql.target" ];
376 after = [ "network.target" ] ++ lib.optionals cfg.database.enable [ "postgresql.target" ];
377 wantedBy = [ "multi-user.target" ];
378 inherit (cfg) environment;
379 path = [
380 # gzip and pg_dumpall are used by the backup service
381 pkgs.gzip
382 postgresqlPackage
383 ];
384
385 serviceConfig = commonServiceConfig // {
386 ExecStart = lib.getExe cfg.package;
387 EnvironmentFile = mkIf (cfg.secretsFile != null) cfg.secretsFile;
388 Slice = "system-immich.slice";
389 StateDirectory = "immich";
390 SyslogIdentifier = "immich";
391 RuntimeDirectory = "immich";
392 User = cfg.user;
393 Group = cfg.group;
394 # ensure that immich-server has permission to connect to the redis socket.
395 SupplementaryGroups = mkIf (cfg.redis.enable && isRedisUnixSocket) [
396 config.services.redis.servers.immich.group
397 ];
398 };
399 };
400
401 systemd.services.immich-machine-learning = mkIf cfg.machine-learning.enable {
402 description = "immich machine learning";
403 requires = lib.mkIf cfg.database.enable [ "postgresql.target" ];
404 after = [ "network.target" ] ++ lib.optionals cfg.database.enable [ "postgresql.target" ];
405 wantedBy = [ "multi-user.target" ];
406 inherit (cfg.machine-learning) environment;
407 serviceConfig = commonServiceConfig // {
408 ExecStart = lib.getExe (cfg.package.machine-learning.override { immich = cfg.package; });
409 Slice = "system-immich.slice";
410 CacheDirectory = "immich";
411 User = cfg.user;
412 Group = cfg.group;
413 };
414 };
415
416 systemd.tmpfiles.settings = {
417 immich = {
418 # Redundant to the `UMask` service config setting on new installs, but installs made in
419 # early 24.11 created world-readable media storage by default, which is a privacy risk. This
420 # fixes those installs.
421 "${cfg.mediaLocation}" = {
422 e = {
423 user = cfg.user;
424 group = cfg.group;
425 mode = "0700";
426 };
427 };
428 };
429 };
430
431 users.users = mkIf (cfg.user == "immich") {
432 immich = {
433 name = "immich";
434 group = cfg.group;
435 isSystemUser = true;
436 };
437 };
438 users.groups = mkIf (cfg.group == "immich") { immich = { }; };
439 };
440 meta = {
441 maintainers = with lib.maintainers; [ jvanbruegge ];
442 doc = ./immich.md;
443 };
444}